From 64c9b636a35a29ba6f37d6d0efabd1e72c534a5a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:46:50 -0800 Subject: [PATCH 01/31] Add `use_async_effect` shielding option --- src/reactpy/core/hooks.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 454dce1ad..1308d141f 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -180,6 +180,7 @@ async def effect(stop: asyncio.Event) -> None: def use_async_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shield: bool = False, ) -> Callable[[_EffectApplyFunc], None]: ... @@ -187,12 +188,14 @@ def use_async_effect( def use_async_effect( function: _AsyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., + shield: bool = False, ) -> None: ... def use_async_effect( function: _AsyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shield: bool = False, ) -> Callable[[_AsyncEffectFunc], None] | None: """ A hook that manages an asynchronous side effect in a React-like component. @@ -209,6 +212,11 @@ def use_async_effect( of any value in the given sequence changes (i.e. their :func:`id` is different). By default these are inferred based on local variables that are referenced by the given function. + shield: + If ``True``, the effect will not be cancelled when the hook is running clean-up. + This can be useful if you want to ensure that the effect runs to completion even if the + component is unmounted while the effect is still running (e.g. a database query). + However, use this option with caution as it can lead to memory leaks. Returns: If not function is provided, a decorator. Otherwise ``None``. @@ -255,9 +263,10 @@ async def effect(stop: asyncio.Event) -> None: await stop.wait() # Stop signal came first - cancel the effect task else: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + # Prevent task cancellation if the user enabled shielding + if not shield: + task.cancel() + await asyncio.shield(task) # Run the clean-up function when the effect is stopped, # if it hasn't been run already by a new effect From 95b823d05f8293319e3c0bf9e3955da533a09cbe Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:48:30 -0800 Subject: [PATCH 02/31] Fix edge case were `strictly_equal` could throw an exception --- src/reactpy/core/hooks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 1308d141f..fa7e260b0 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -597,9 +597,12 @@ def strictly_equal(x: Any, y: Any) -> bool: # Compare the source code of lambda and local functions if ( - hasattr(x, "__qualname__") + getattr(x, "__qualname__", "") + and getattr(y, "__qualname__", "") and ("" in x.__qualname__ or "" in x.__qualname__) + and ("" in y.__qualname__ or "" in y.__qualname__) and hasattr(x, "__code__") + and hasattr(y, "__code__") ): if x.__qualname__ != y.__qualname__: return False From 726cd5227184fce12a9978c1c90b099979f1220c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:49:41 -0800 Subject: [PATCH 03/31] Add max queue size setting --- src/reactpy/config.py | 8 ++++++++ src/reactpy/core/layout.py | 6 ++++-- src/reactpy/executors/asgi/middleware.py | 4 +++- tests/test_core/test_serve.py | 5 +++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/reactpy/config.py b/src/reactpy/config.py index f276fb532..d9d4f760c 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -130,3 +130,11 @@ def boolean(value: str | bool | int) -> bool: validator=str, ) """The prefix for all ReactPy routes""" + +REACTPY_MAX_QUEUE_SIZE = Option( + "REACTPY_MAX_QUEUE_SIZE", + default=1000, + mutable=True, + validator=int, +) +"""The maximum size for internal queues used by ReactPy""" diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 3863213de..546c9a516 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -34,6 +34,7 @@ REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG, + REACTPY_MAX_QUEUE_SIZE, ) from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.vdom import validate_vdom_json @@ -117,7 +118,8 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: target = event["target"] if target not in self._event_queues: self._event_queues[target] = cast( - "Queue[LayoutEventMessage | dict[str, Any]]", Queue() + "Queue[LayoutEventMessage | dict[str, Any]]", + Queue(REACTPY_MAX_QUEUE_SIZE.current), ) self._event_processing_tasks[target] = create_task( self._process_event_queue(target, self._event_queues[target]) @@ -759,7 +761,7 @@ class _LifeCycleState(NamedTuple): class _ThreadSafeQueue(Generic[_Type]): def __init__(self) -> None: self._loop = get_running_loop() - self._queue: Queue[_Type] = Queue() + self._queue: Queue[_Type] = Queue(REACTPY_MAX_QUEUE_SIZE.current) self._pending: set[_Type] = set() def put(self, value: _Type) -> None: diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 551652fb5..6d3505998 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -186,7 +186,9 @@ def __init__( super().__init__(scope=scope, receive=receive, send=send) # type: ignore self.scope = scope self.parent = parent - self.rendering_queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + self.rendering_queue: asyncio.Queue[dict[str, str]] = asyncio.Queue( + config.REACTPY_MAX_QUEUE_SIZE.current + ) self.dispatcher: asyncio.Task[Any] | None = None async def __aenter__(self) -> ReactPyWebsocket: diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index d0e5b5f15..20ec0d8d5 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -7,6 +7,7 @@ from jsonpointer import set_pointer import reactpy +from reactpy.config import REACTPY_MAX_QUEUE_SIZE from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout @@ -130,8 +131,8 @@ def set_did_render(): reactpy.html.button({"onClick": handle_event}), ) - send_queue = asyncio.Queue() - recv_queue = asyncio.Queue() + send_queue = asyncio.Queue(REACTPY_MAX_QUEUE_SIZE.current) + recv_queue = asyncio.Queue(REACTPY_MAX_QUEUE_SIZE.current) task = asyncio.create_task( serve_layout( From 5171a1ac1ecd609441d26b809305f8512ad98edb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:50:40 -0800 Subject: [PATCH 04/31] Move `GITHUB_ACTIONS` out of testing module --- src/reactpy/testing/common.py | 3 --- src/reactpy/utils.py | 14 +++++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index bcfce2ebd..48f9cb9dc 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -2,7 +2,6 @@ import asyncio import inspect -import os import time from collections.abc import Awaitable, Callable, Coroutine from functools import wraps @@ -13,14 +12,12 @@ from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.utils import str_to_bool _P = ParamSpec("_P") _R = TypeVar("_R") _DEFAULT_POLL_DELAY = 0.1 -GITHUB_ACTIONS = str_to_bool(os.getenv("GITHUB_ACTIONS", "")) class poll(Generic[_R]): # noqa: N801 diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 4966f9f4e..e97de6dcb 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re from collections.abc import Callable, Iterable from importlib import import_module @@ -16,6 +17,14 @@ _RefValue = TypeVar("_RefValue") _ModelTransform = Callable[[VdomDict], Any] _UNDEFINED: Any = object() +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() in { + "y", + "yes", + "t", + "true", + "on", + "1", +} class Ref(Generic[_RefValue]): @@ -309,8 +318,3 @@ def __new__(cls, *args, **kw): orig = super() cls._instance = orig.__new__(cls, *args, **kw) return cls._instance - - -def str_to_bool(s: str) -> bool: - """Convert a string to a boolean value.""" - return s.lower() in {"y", "yes", "t", "true", "on", "1"} From fabfb88371334802c13c02a6dcac4177874be7ed Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:51:11 -0800 Subject: [PATCH 05/31] Make pyscript utils more extensible --- src/reactpy/executors/pyscript/utils.py | 21 ++++++++++++--------- tests/test_core/test_hooks.py | 2 -- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index 90f1d235a..e6289d01a 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -7,6 +7,7 @@ import shutil import subprocess import textwrap +from collections.abc import Callable from glob import glob from logging import getLogger from pathlib import Path @@ -17,7 +18,7 @@ import reactpy from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR from reactpy.types import VdomDict -from reactpy.utils import reactpy_to_string +from reactpy.utils import GITHUB_ACTIONS, reactpy_to_string if TYPE_CHECKING: from collections.abc import Sequence @@ -47,15 +48,18 @@ def minify_python(source: str) -> str: ) -def pyscript_executor_html(file_paths: Sequence[str], uuid: str, root: str) -> str: +def pyscript_executor_html( + file_paths: Sequence[str], uuid: str, root: str, cache_handler: Callable +) -> str: """Inserts the user's code into the PyScript template using pattern matching.""" # Create a valid PyScript executor by replacing the template values executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) executor = executor.replace("return root()", f"return {root}()") # Fetch the user's PyScript code + cache_handler = cache_handler or fetch_cached_python_file all_file_contents: list[str] = [] - all_file_contents.extend(cached_file_read(file_path) for file_path in file_paths) + all_file_contents.extend(cache_handler(file_path) for file_path in file_paths) # Prepare the PyScript code block user_code = "\n".join(all_file_contents) # Combine all user code @@ -110,12 +114,14 @@ def extend_pyscript_config( extra_py: Sequence[str], extra_js: dict[str, str] | str, config: dict[str, Any] | str, + modules: dict[str, str] | str | None = None, ) -> str: # Extends ReactPy's default PyScript config with user provided values. pyscript_config: dict[str, Any] = { "packages": [reactpy_version_string(), "jsonpointer==3.*", "ssl"], "js_modules": { - "main": { + "main": modules + or { f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" } }, @@ -141,12 +147,9 @@ def extend_pyscript_config( def reactpy_version_string() -> str: # nocov - from reactpy.testing.common import GITHUB_ACTIONS - - local_version = reactpy.__version__ - # Get a list of all versions via `pip index versions` result = get_reactpy_versions() + local_version = reactpy.__version__ # Check if the command failed if not result: @@ -231,6 +234,6 @@ def get_reactpy_versions() -> dict[Any, Any]: @functools.cache -def cached_file_read(file_path: str, minifiy: bool = True) -> str: +def fetch_cached_python_file(file_path: str, minifiy: bool = True) -> str: content = Path(file_path).read_text(encoding="utf-8").strip() return minify_python(content) if minifiy else content diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index a4e59f3fd..057edf4e6 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -634,7 +634,6 @@ async def effect(): await asyncio.wait_for(effect_was_cancelled.wait(), 1) - async def test_error_in_effect_is_gracefully_handled(): @reactpy.component def ComponentWithEffect(): @@ -1420,4 +1419,3 @@ async def effect(): # Verify the previous effect was cancelled await asyncio.wait_for(effect_was_cancelled.wait(), 1) - From 15b6afb5476f71d165c360599a75993b811a0eff Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:51:26 -0800 Subject: [PATCH 06/31] Add missing changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70333520f..96423cce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Don't forget to remove deprecated code on each major release! - Fixed a bug where script elements would not render to the DOM as plain text. - Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components. - Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads. +- Fixed a bug where events (server to client, and client to server) could be lost during rapid actions. - Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy. ## [1.1.0] - 2024-11-24 From a13d21afff8ca5ad56d0e7b363d8421c37d71376 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:09:15 -0800 Subject: [PATCH 07/31] fix CI errors --- src/reactpy/core/hooks.py | 3 +- src/reactpy/executors/pyscript/utils.py | 5 ++- src/reactpy/testing/__init__.py | 9 ++++- src/reactpy/testing/common.py | 2 + tests/test_client.py | 3 +- tests/test_core/test_events.py | 3 +- tests/test_core/test_hooks.py | 50 ++++++++++++++++++++++++- tests/test_html.py | 3 +- tests/test_widgets.py | 3 +- tests/tooling/common.py | 3 -- 10 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index fa7e260b0..b986c60ae 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -266,7 +266,8 @@ async def effect(stop: asyncio.Event) -> None: # Prevent task cancellation if the user enabled shielding if not shield: task.cancel() - await asyncio.shield(task) + with contextlib.suppress(asyncio.CancelledError): + await task # Run the clean-up function when the effect is stopped, # if it hasn't been run already by a new effect diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index e6289d01a..fba59c453 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -49,7 +49,10 @@ def minify_python(source: str) -> str: def pyscript_executor_html( - file_paths: Sequence[str], uuid: str, root: str, cache_handler: Callable + file_paths: Sequence[str], + uuid: str, + root: str, + cache_handler: Callable | None = None, ) -> str: """Inserts the user's code into the PyScript template using pattern matching.""" # Create a valid PyScript executor by replacing the template values diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 67439ea36..32f1a06d2 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -1,5 +1,10 @@ from reactpy.testing.backend import BackendFixture -from reactpy.testing.common import GITHUB_ACTIONS, HookCatcher, StaticEventHandler, poll +from reactpy.testing.common import ( + DEFAULT_TYPE_DELAY, + HookCatcher, + StaticEventHandler, + poll, +) from reactpy.testing.display import DisplayFixture from reactpy.testing.logs import ( LogAssertionError, @@ -9,7 +14,7 @@ ) __all__ = [ - "GITHUB_ACTIONS", + "DEFAULT_TYPE_DELAY", "BackendFixture", "DisplayFixture", "HookCatcher", diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 48f9cb9dc..1577e57f5 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -12,12 +12,14 @@ from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.utils import GITHUB_ACTIONS _P = ParamSpec("_P") _R = TypeVar("_R") _DEFAULT_POLL_DELAY = 0.1 +DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 25 class poll(Generic[_R]): # noqa: N801 diff --git a/tests/test_client.py b/tests/test_client.py index e05286f74..3dc3c6095 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,8 +2,7 @@ from pathlib import Path import reactpy -from reactpy.testing import BackendFixture, DisplayFixture, poll -from tests.tooling.common import DEFAULT_TYPE_DELAY +from reactpy.testing import DEFAULT_TYPE_DELAY, BackendFixture, DisplayFixture, poll from tests.tooling.hooks import use_counter JS_DIR = Path(__file__).parent / "js" diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 9da6ba7f9..b6d2e59e8 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -12,9 +12,8 @@ to_event_handler_function, ) from reactpy.core.layout import Layout -from reactpy.testing import DisplayFixture, poll +from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll from reactpy.types import Event -from tests.tooling.common import DEFAULT_TYPE_DELAY def test_event_handler_repr(): diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 057edf4e6..ae509c161 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -8,10 +8,16 @@ from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.hooks import strictly_equal, use_effect from reactpy.core.layout import Layout -from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll +from reactpy.testing import ( + DEFAULT_TYPE_DELAY, + DisplayFixture, + HookCatcher, + assert_reactpy_did_log, + poll, +) from reactpy.testing.logs import assert_reactpy_did_not_log from reactpy.utils import Ref -from tests.tooling.common import DEFAULT_TYPE_DELAY, update_message +from tests.tooling.common import update_message async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -600,6 +606,46 @@ async def effect(): event_that_never_occurs.set() +async def test_use_async_effect_shield(): + component_hook = HookCatcher() + effect_ran = asyncio.Event() + effect_finished = asyncio.Event() + stop_waiting = asyncio.Event() + + @reactpy.component + @component_hook.capture + def ComponentWithShieldedEffect(): + @reactpy.hooks.use_async_effect(dependencies=None, shield=True) + async def effect(): + effect_ran.set() + await stop_waiting.wait() + effect_finished.set() + + return reactpy.html.div() + + async with Layout(ComponentWithShieldedEffect()) as layout: + await layout.render() + + await effect_ran.wait() + + # Trigger re-render which would normally cancel the effect + component_hook.latest.schedule_render() + + # Give the loop a chance to process the render logic and potentially cancel + await asyncio.sleep(0.1) + + # Verify effect hasn't finished yet but also wasn't cancelled + assert not effect_finished.is_set() + + # Now allow the effect to finish + stop_waiting.set() + + # The re-render should complete now that the shielded effect is done + await layout.render() + + await asyncio.wait_for(effect_finished.wait(), 1) + + async def test_async_effect_sleep_is_cancelled_on_re_render(): """Test that async effects waiting on asyncio.sleep are properly cancelled.""" component_hook = HookCatcher() diff --git a/tests/test_html.py b/tests/test_html.py index 930b3e2fb..97189a4ad 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -2,9 +2,8 @@ from playwright.async_api import expect from reactpy import component, config, hooks, html -from reactpy.testing import DisplayFixture, poll +from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll from reactpy.utils import Ref -from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter diff --git a/tests/test_widgets.py b/tests/test_widgets.py index fd44bd9f4..9841fc231 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -2,8 +2,7 @@ from pathlib import Path import reactpy -from reactpy.testing import DisplayFixture, poll -from tests.tooling.common import DEFAULT_TYPE_DELAY +from reactpy.testing import DEFAULT_TYPE_DELAY, DisplayFixture, poll HERE = Path(__file__).parent diff --git a/tests/tooling/common.py b/tests/tooling/common.py index 75495db0c..48ac8122b 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,10 +1,7 @@ from typing import Any -from reactpy.testing.common import GITHUB_ACTIONS from reactpy.types import LayoutEventMessage, LayoutUpdateMessage -DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 50 - def event_message(target: str, *data: Any) -> LayoutEventMessage: return {"type": "layout-event", "target": target, "data": data} From 8c68d4a633bf64843c9d887a955eca9479685e69 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:30:12 -0800 Subject: [PATCH 08/31] Pyscript now supports ContextVars! --- src/reactpy/core/_life_cycle_hook.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index e0f88a169..cf77742ea 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import sys from asyncio import Event, Task, create_task, gather from collections.abc import Callable from contextvars import ContextVar, Token @@ -28,9 +27,7 @@ class _HookStack(Singleton): # nocov Life cycle hooks can be stored in a thread local or context variable depending on the platform.""" - _state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = ( - ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state") - ) + _state: ContextVar[list[LifeCycleHook]] = ContextVar("hook_state") def get(self) -> list[LifeCycleHook]: try: @@ -268,5 +265,14 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if HOOK_STACK.get().pop() is not self: - raise RuntimeError("Hook stack is in an invalid state") # nocov + hook_stack = HOOK_STACK.get() + if not hook_stack: + raise RuntimeError( # nocov + "Attempting to unset current life cycle hook but it no longer exists!\n" + "A separate process or thread may have deleted this component's hook stack!" + ) + if hook_stack and hook_stack.pop() is not self: + raise RuntimeError( # nocov + "Hook stack is in an invalid state\n" + "A separate process or thread may have modified this component's hook stack!" + ) From b5be02e8d784459248b36342e4319907f5d4396f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:30:32 -0800 Subject: [PATCH 09/31] Revert "Move `GITHUB_ACTIONS` out of testing module" This reverts commit 5171a1ac1ecd609441d26b809305f8512ad98edb. --- src/reactpy/executors/pyscript/utils.py | 3 ++- src/reactpy/testing/backend.py | 21 ++++++++++------ src/reactpy/testing/common.py | 33 +++++++++++++++++++------ src/reactpy/testing/display.py | 6 +++-- src/reactpy/testing/logs.py | 4 +-- src/reactpy/utils.py | 9 ------- 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index fba59c453..fc93157cd 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -17,8 +17,9 @@ import reactpy from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR +from reactpy.testing.common import GITHUB_ACTIONS from reactpy.types import VdomDict -from reactpy.utils import GITHUB_ACTIONS, reactpy_to_string +from reactpy.utils import reactpy_to_string if TYPE_CHECKING: from collections.abc import Sequence diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index ca311ceed..998cb7d51 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -6,23 +6,21 @@ from collections.abc import Callable from contextlib import AsyncExitStack from types import TracebackType -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlunparse import uvicorn -from reactpy.core.component import component -from reactpy.core.hooks import use_callback, use_effect, use_state -from reactpy.executors.asgi.middleware import ReactPyMiddleware -from reactpy.executors.asgi.standalone import ReactPy -from reactpy.executors.asgi.types import AsgiApp from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, list_logged_exceptions, ) -from reactpy.types import ComponentConstructor -from reactpy.utils import Ref + +if TYPE_CHECKING: + from reactpy.executors.asgi.types import AsgiApp + from reactpy.types import ComponentConstructor + from reactpy.utils import Ref class BackendFixture: @@ -48,6 +46,9 @@ def __init__( port: int | None = None, **reactpy_config: Any, ) -> None: + from reactpy.executors.asgi.middleware import ReactPyMiddleware + from reactpy.executors.asgi.standalone import ReactPy + self.host = host self.port = port or 0 self.mount = mount_to_hotswap @@ -201,6 +202,10 @@ def DivTwo(self): # displaying the output now will show DivTwo """ + from reactpy.core.component import component + from reactpy.core.hooks import use_callback, use_effect, use_state + from reactpy.utils import Ref + constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: None) if update_on_change: diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 1577e57f5..63d003b4b 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -2,23 +2,31 @@ import asyncio import inspect +import os import time from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Generic, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar, cast from uuid import uuid4 from weakref import ref -from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT -from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook -from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.utils import GITHUB_ACTIONS +if TYPE_CHECKING: + from reactpy.core._life_cycle_hook import LifeCycleHook + from reactpy.core.events import EventHandler _P = ParamSpec("_P") _R = TypeVar("_R") _DEFAULT_POLL_DELAY = 0.1 +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() in { + "y", + "yes", + "t", + "true", + "on", + "1", +} DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 25 @@ -47,11 +55,16 @@ async def async_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: async def until( self, condition: Callable[[_R], bool], - timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, + timeout: float | None = None, delay: float = _DEFAULT_POLL_DELAY, description: str = "condition to be true", ) -> None: """Check that the coroutines result meets a condition within the timeout""" + if timeout is None: + from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT + + timeout = REACTPY_TESTS_DEFAULT_TIMEOUT.current + started_at = time.time() while True: await asyncio.sleep(delay) @@ -65,7 +78,7 @@ async def until( async def until_is( self, right: _R, - timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, + timeout: float | None = None, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is identical to the given value""" @@ -79,7 +92,7 @@ async def until_is( async def until_equals( self, right: _R, - timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, + timeout: float | None = None, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is equal to the given value""" @@ -132,6 +145,8 @@ def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: @wraps(render_function) def wrapper(*args: Any, **kwargs: Any) -> Any: + from reactpy.core._life_cycle_hook import HOOK_STACK + self = self_ref() if self is None: raise RuntimeError("Hook catcher has been garbage collected") @@ -196,6 +211,8 @@ def use( stop_propagation: bool = False, prevent_default: bool = False, ) -> EventHandler: + from reactpy.core.events import EventHandler, to_event_handler_function + return EventHandler( to_event_handler_function(function), stop_propagation, diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index 4dc4c53cb..5582673fb 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -8,13 +8,13 @@ from playwright.async_api import Browser, Page, async_playwright, expect -from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT as DEFAULT_TIMEOUT from reactpy.testing.backend import BackendFixture -from reactpy.types import RootComponentConstructor if TYPE_CHECKING: import pytest + from reactpy.types import RootComponentConstructor + _logger = getLogger(__name__) @@ -32,6 +32,8 @@ def __init__( headless: bool = False, timeout: float | None = None, ) -> None: + from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT as DEFAULT_TIMEOUT + if backend: self.backend_is_external = True self.backend = backend diff --git a/src/reactpy/testing/logs.py b/src/reactpy/testing/logs.py index 38470ea4f..3d72262fd 100644 --- a/src/reactpy/testing/logs.py +++ b/src/reactpy/testing/logs.py @@ -7,8 +7,6 @@ from traceback import format_exception from typing import Any, NoReturn -from reactpy.logging import ROOT_LOGGER - class LogAssertionError(AssertionError): """An assertion error raised in relation to log messages.""" @@ -127,6 +125,8 @@ def capture_reactpy_logs() -> Iterator[list[logging.LogRecord]]: Any logs produced in this context are cleared afterwards """ + from reactpy.logging import ROOT_LOGGER + original_level = ROOT_LOGGER.level ROOT_LOGGER.setLevel(logging.DEBUG) try: diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index e97de6dcb..bb0bc5b3b 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import re from collections.abc import Callable, Iterable from importlib import import_module @@ -17,14 +16,6 @@ _RefValue = TypeVar("_RefValue") _ModelTransform = Callable[[VdomDict], Any] _UNDEFINED: Any = object() -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() in { - "y", - "yes", - "t", - "true", - "on", - "1", -} class Ref(Generic[_RefValue]): From 247e329ba97695a9f94004f8380b07960ded8dab Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:31:32 -0800 Subject: [PATCH 10/31] Always append URL and QS to base websocket --- src/js/packages/@reactpy/client/src/websocket.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts index 4c72620f0..b88af5176 100644 --- a/src/js/packages/@reactpy/client/src/websocket.ts +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -15,6 +15,8 @@ export function createReconnectingWebSocket( if (closed) { return; } + props.url.searchParams.set("path", window.location.pathname); + props.url.searchParams.set("qs", window.location.search); socket.current = new WebSocket(props.url); socket.current.onopen = () => { everConnected = true; From 2040b45883bc3df8eac7042bdd6eca7081c8a3a5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:31:40 -0800 Subject: [PATCH 11/31] bump version number --- src/reactpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 422f16781..293701b1f 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -23,7 +23,7 @@ from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy __author__ = "The Reactive Python Team" -__version__ = "2.0.0b10" +__version__ = "2.0.0b11" __all__ = [ "Ref", From e4dd8f694d32108d7d934504bd35f8aefec78a3b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:31:50 -0800 Subject: [PATCH 12/31] fix test failures --- src/reactpy/executors/pyscript/utils.py | 3 ++- tests/test_asgi/test_standalone.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index fc93157cd..0c8eeeb1a 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -17,7 +17,6 @@ import reactpy from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR -from reactpy.testing.common import GITHUB_ACTIONS from reactpy.types import VdomDict from reactpy.utils import reactpy_to_string @@ -151,6 +150,8 @@ def extend_pyscript_config( def reactpy_version_string() -> str: # nocov + from reactpy.testing.common import GITHUB_ACTIONS + # Get a list of all versions via `pip index versions` result = get_reactpy_versions() local_version = reactpy.__version__ diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 2d4baa544..69fbf9f7d 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -8,9 +8,9 @@ import reactpy from reactpy import html +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.executors.asgi.standalone import ReactPy from reactpy.testing import BackendFixture, DisplayFixture, poll -from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.types import Connection, Location From 18791f9002ed731e75d7e2cf1e385d307de72c81 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:39:17 -0800 Subject: [PATCH 13/31] Add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96423cce7..f33ab50bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Don't forget to remove deprecated code on each major release! - Added `reactpy.reactjs.component_from_string` to import ReactJS components from a string. - Added `reactpy.reactjs.component_from_npm` to import ReactJS components from NPM. - Added `reactpy.h` as a shorthand alias for `reactpy.html`. +- Added `reactpy.config.REACTPY_MAX_QUEUE_SIZE` to configure the maximum size of all ReactPy asyncio queues (e.g. receive buffer, send buffer, event buffer) before ReactPy begins waiting until a slot frees up. This can be used to constraint memory usage. ### Changed From 52be92af3fb28fe6ada716d27dceeae6a0904b01 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:26:42 -0800 Subject: [PATCH 14/31] Bump client version --- src/js/packages/@reactpy/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index 7545c8dbc..6708d6745 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -36,5 +36,5 @@ "checkTypes": "tsc --noEmit" }, "type": "module", - "version": "1.1.0" + "version": "1.1.1" } From 773bf7ea662ca886baa49efdfbc4a92ee6d4a7b2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:29:25 -0800 Subject: [PATCH 15/31] self review --- CHANGELOG.md | 1 + src/reactpy/testing/__init__.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f33ab50bf..9b33bff26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Don't forget to remove deprecated code on each major release! - Removed `reactpy.run`. See the documentation for the new method to run ReactPy applications. - Removed `reactpy.backend.*`. See the documentation for the new method to run ReactPy applications. - Removed `reactpy.core.types` module. Use `reactpy.types` instead. +- Removed `reactpy.utils.str_to_bool`. - Removed `reactpy.utils.html_to_vdom`. Use `reactpy.utils.string_to_reactpy` instead. - Removed `reactpy.utils.vdom_to_html`. Use `reactpy.utils.reactpy_to_string` instead. - Removed `reactpy.vdom`. Use `reactpy.Vdom` instead. diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 32f1a06d2..fae8aac71 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -1,6 +1,7 @@ from reactpy.testing.backend import BackendFixture from reactpy.testing.common import ( DEFAULT_TYPE_DELAY, + GITHUB_ACTIONS, HookCatcher, StaticEventHandler, poll, @@ -15,6 +16,7 @@ __all__ = [ "DEFAULT_TYPE_DELAY", + "GITHUB_ACTIONS", "BackendFixture", "DisplayFixture", "HookCatcher", From abbb27f5bd4d0c27fc8504655d3ce7f375de4feb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:39:34 -0800 Subject: [PATCH 16/31] debounce user inputs to prevent character loss --- .../@reactpy/client/src/components.tsx | 20 +++++++++-- tests/test_core/test_events.py | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 52f229bae..730c3e64e 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -82,16 +82,32 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { const client = useContext(ClientContext); const props = createAttributes(model, client); const [value, setValue] = useState(props.value); + const lastUserValue = useRef(props.value); + const lastChangeTime = useRef(0); // honor changes to value from the client via props - useEffect(() => setValue(props.value), [props.value]); + useEffect(() => { + // If the new prop value matches what we last sent, we are in sync. + // If it differs, we only update if sufficient time has passed since user input, + // effectively debouncing server overrides during rapid typing. + const now = Date.now(); + if ( + props.value === lastUserValue.current || + now - lastChangeTime.current > 200 + ) { + setValue(props.value); + } + }, [props.value]); const givenOnChange = props.onChange; if (typeof givenOnChange === "function") { props.onChange = (event: TargetedEvent) => { // immediately update the value to give the user feedback if (event.target) { - setValue((event.target as HTMLInputElement).value); + const newValue = (event.target as HTMLInputElement).value; + setValue(newValue); + lastUserValue.current = newValue; + lastChangeTime.current = Date.now(); } // allow the client to respond (and possibly change the value) givenOnChange(event); diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index b6d2e59e8..7fd857b31 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -586,3 +586,39 @@ async def add_top(event): await btn_b.click() # This generates event for .../1 assert clicked_items == ["B"] + + +async def test_controlled_input_typing(display: DisplayFixture): + """ + Test that a controlled input updates correctly even with rapid typing. + This validates that event queueing/processing order is maintained. + """ + + @reactpy.component + def ControlledInput(): + value, set_value = use_state("") + + def on_change(event): + set_value(event["target"]["value"]) + + return reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + } + ) + + await display.show(ControlledInput) + + inp = await display.page.wait_for_selector("#controlled-input") + + # Type a long string rapidly + target_text = "hello world this is a test" + await inp.type(target_text, delay=0) + + # Wait a bit for all events to settle + await asyncio.sleep(0.5) + + # Check the final value + assert (await inp.evaluate("node => node.value")) == target_text From 72e1974022f3789d17d87a5f5712450e1501b2f5 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 13 Apr 2026 21:43:45 -0400 Subject: [PATCH 17/31] Configurable debounce --- CHANGELOG.md | 1 + .../@reactpy/client/src/components.tsx | 65 +++++++++++--- src/js/packages/@reactpy/client/src/types.ts | 1 + src/js/packages/@reactpy/client/src/vdom.tsx | 16 +++- src/reactpy/core/events.py | 74 +++++++++++----- src/reactpy/core/layout.py | 10 +++ src/reactpy/core/vdom.py | 1 + src/reactpy/types.py | 7 ++ tests/test_core/test_events.py | 88 ++++++++++++++++++- tests/test_core/test_vdom.py | 21 +++++ 10 files changed, 246 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33bff26..2b98f6393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Don't forget to remove deprecated code on each major release! - `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`. - `REACTPY_ASYNC_RENDERING` can now de-duplicate and cascade renders where necessary. - `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency. +- Events now support debounce, which can now be configured per event with `event.debounce = `. Note that `input`, `select`, and `textarea` elements default to 200ms debounce. ### Deprecated diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 730c3e64e..3bc97242b 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -18,6 +18,34 @@ import type { ReactPyClient } from "./client"; const ClientContext = createContext(null as any); +const DEFAULT_INPUT_DEBOUNCE = 200; + +type ReactPyInputHandler = ((event: TargetedEvent) => void) & { + debounce?: number; + isHandler?: boolean; +}; + +type UserInputTarget = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +function trackUserInput( + event: TargetedEvent, + setValue: (value: any) => void, + lastUserValue: MutableRefObject, + lastChangeTime: MutableRefObject, + lastInputDebounce: MutableRefObject, + debounce: number, +): void { + if (!event.target) { + return; + } + + const newValue = (event.target as UserInputTarget).value; + setValue(newValue); + lastUserValue.current = newValue; + lastChangeTime.current = Date.now(); + lastInputDebounce.current = debounce; +} + export function Layout(props: { client: ReactPyClient }): JSX.Element { const currentModel: ReactPyVdom = useState({ tagName: "" })[0]; const forceUpdate = useForceUpdate(); @@ -84,6 +112,7 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { const [value, setValue] = useState(props.value); const lastUserValue = useRef(props.value); const lastChangeTime = useRef(0); + const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE); // honor changes to value from the client via props useEffect(() => { @@ -93,24 +122,34 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { const now = Date.now(); if ( props.value === lastUserValue.current || - now - lastChangeTime.current > 200 + now - lastChangeTime.current >= lastInputDebounce.current ) { setValue(props.value); } }, [props.value]); - const givenOnChange = props.onChange; - if (typeof givenOnChange === "function") { - props.onChange = (event: TargetedEvent) => { - // immediately update the value to give the user feedback - if (event.target) { - const newValue = (event.target as HTMLInputElement).value; - setValue(newValue); - lastUserValue.current = newValue; - lastChangeTime.current = Date.now(); - } - // allow the client to respond (and possibly change the value) - givenOnChange(event); + for (const [name, prop] of Object.entries(props)) { + if (typeof prop !== "function") { + continue; + } + + const givenHandler = prop as ReactPyInputHandler; + if (!givenHandler.isHandler) { + continue; + } + + props[name] = (event: TargetedEvent) => { + trackUserInput( + event, + setValue, + lastUserValue, + lastChangeTime, + lastInputDebounce, + typeof givenHandler.debounce === "number" + ? givenHandler.debounce + : DEFAULT_INPUT_DEBOUNCE, + ); + givenHandler(event); }; } diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 12bc8f3fa..209d063d1 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -60,6 +60,7 @@ export type ReactPyVdomEventHandler = { target: string; preventDefault?: boolean; stopPropagation?: boolean; + debounce?: number; }; export type ReactPyVdomImportSource = { diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 8b289fceb..ccb41ad23 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -206,7 +206,7 @@ export function createAttributes( function createEventHandler( client: ReactPyClient, name: string, - { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, + { target, preventDefault, stopPropagation, debounce }: ReactPyVdomEventHandler, ): [string, () => void] { const eventHandler = function (...args: any[]) { const data = Array.from(args).map((value) => { @@ -227,7 +227,19 @@ function createEventHandler( }); client.sendMessage({ type: "layout-event", data, target }); }; - eventHandler.isHandler = true; + ( + eventHandler as typeof eventHandler & { + debounce?: number; + isHandler: boolean; + } + ).isHandler = true; + if (typeof debounce === "number") { + ( + eventHandler as typeof eventHandler & { + debounce?: number; + } + ).debounce = debounce; + } return [name, eventHandler]; } diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index c1dec839c..09a017e0c 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -18,6 +18,7 @@ def event( *, stop_propagation: bool = ..., prevent_default: bool = ..., + debounce: int | None = ..., ) -> EventHandler: ... @@ -27,6 +28,7 @@ def event( *, stop_propagation: bool = ..., prevent_default: bool = ..., + debounce: int | None = ..., ) -> Callable[[Callable[..., Any]], EventHandler]: ... @@ -35,6 +37,7 @@ def event( *, stop_propagation: bool = False, prevent_default: bool = False, + debounce: int | None = None, ) -> EventHandler | Callable[[Callable[..., Any]], EventHandler]: """A decorator for constructing an :class:`EventHandler`. @@ -63,6 +66,9 @@ def my_callback(*data): ... Block the event from propagating further up the DOM. prevent_default: Stops the default actional associate with the event from taking place. + debounce: + Preserve client-side user input state for the given number of milliseconds + before applying conflicting server updates. """ def setup(function: Callable[..., Any]) -> EventHandler: @@ -70,6 +76,7 @@ def setup(function: Callable[..., Any]) -> EventHandler: to_event_handler_function(function, positional_args=True), stop_propagation, prevent_default, + debounce=debounce, ) return setup(function) if function is not None else setup @@ -95,10 +102,12 @@ def __init__( stop_propagation: bool = False, prevent_default: bool = False, target: str | None = None, + debounce: int | None = None, ) -> None: self.function = to_event_handler_function(function, positional_args=False) self.prevent_default = prevent_default self.stop_propagation = stop_propagation + self.debounce = debounce self.target = target # Check if our `preventDefault` or `stopPropagation` methods were called @@ -110,14 +119,16 @@ def __init__( if isinstance(func_to_inspect, partial): func_to_inspect = func_to_inspect.func - found_prevent_default, found_stop_propagation = _inspect_event_handler_code( - func_to_inspect.__code__ + found_prevent_default, found_stop_propagation, found_debounce = ( + _inspect_event_handler_code(func_to_inspect.__code__) ) if found_prevent_default: self.prevent_default = True if found_stop_propagation: self.stop_propagation = True + if found_debounce is not None: + self.debounce = found_debounce __hash__ = None # type: ignore @@ -130,6 +141,7 @@ def __eq__(self, other: object) -> bool: "function", "prevent_default", "stop_propagation", + "debounce", "target", ) ) @@ -184,8 +196,9 @@ def merge_event_handlers( """Merge multiple event handlers into one Raises a ValueError if any handlers have conflicting - :attr:`~reactpy.core.proto.EventHandlerType.stop_propagation` or - :attr:`~reactpy.core.proto.EventHandlerType.prevent_default` attributes. + :attr:`~reactpy.core.proto.EventHandlerType.stop_propagation`, + :attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, or + :attr:`~reactpy.core.proto.EventHandlerType.debounce` attributes. """ if not event_handlers: msg = "No event handlers to merge" @@ -197,15 +210,20 @@ def merge_event_handlers( stop_propagation = first_handler.stop_propagation prevent_default = first_handler.prevent_default + debounce = first_handler.debounce target = first_handler.target for handler in event_handlers: if ( handler.stop_propagation != stop_propagation or handler.prevent_default != prevent_default + or handler.debounce != debounce or handler.target != target ): - msg = "Cannot merge handlers - 'stop_propagation', 'prevent_default' or 'target' mismatch." + msg = ( + "Cannot merge handlers - 'stop_propagation', 'prevent_default', " + "'debounce' or 'target' mismatch." + ) raise ValueError(msg) return EventHandler( @@ -213,6 +231,7 @@ def merge_event_handlers( stop_propagation, prevent_default, target, + debounce, ) @@ -235,22 +254,25 @@ async def await_all_event_handlers(data: Sequence[Any]) -> None: @lru_cache(maxsize=4096) -def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool]: +def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool, int | None]: prevent_default = False stop_propagation = False + debounce = None if code.co_argcount > 0: names = code.co_names check_prevent_default = "preventDefault" in names check_stop_propagation = "stopPropagation" in names + check_debounce = "debounce" in names - if not (check_prevent_default or check_stop_propagation): - return False, False + if not (check_prevent_default or check_stop_propagation or check_debounce): + return False, False, None event_arg_name = code.co_varnames[0] last_was_event = False + instructions = list(dis.get_instructions(code)) - for instr in dis.get_instructions(code): + for index, instr in enumerate(instructions): if ( instr.opname in ("LOAD_FAST", "LOAD_FAST_BORROW") and instr.argval == event_arg_name @@ -258,20 +280,28 @@ def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool]: last_was_event = True continue - if last_was_event and instr.opname in ( - "LOAD_METHOD", - "LOAD_ATTR", - ): - if check_prevent_default and instr.argval == "preventDefault": - prevent_default = True - check_prevent_default = False - elif check_stop_propagation and instr.argval == "stopPropagation": - stop_propagation = True - check_stop_propagation = False - - if not (check_prevent_default or check_stop_propagation): + if last_was_event: + if instr.opname in ("LOAD_METHOD", "LOAD_ATTR"): + if check_prevent_default and instr.argval == "preventDefault": + prevent_default = True + check_prevent_default = False + elif check_stop_propagation and instr.argval == "stopPropagation": + stop_propagation = True + check_stop_propagation = False + elif check_debounce and instr.opname == "STORE_ATTR": + if instr.argval == "debounce" and index > 1: + candidate = instructions[index - 2].argval + if isinstance(candidate, int) and not isinstance( + candidate, bool + ): + debounce = candidate + check_debounce = False + + if not ( + check_prevent_default or check_stop_propagation or check_debounce + ): break last_was_event = False - return prevent_default, stop_propagation + return prevent_default, stop_propagation, debounce diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 546c9a516..0f9cbe5fa 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -401,6 +401,11 @@ def _render_model_attributes( "target": target, "preventDefault": handler.prevent_default, "stopPropagation": handler.stop_propagation, + **( + {"debounce": handler.debounce} + if handler.debounce is not None + else {} + ), } return None @@ -426,6 +431,11 @@ def _render_model_event_handlers_without_old_state( "target": target, "preventDefault": handler.prevent_default, "stopPropagation": handler.stop_propagation, + **( + {"debounce": handler.debounce} + if handler.debounce is not None + else {} + ), } return None diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 08592a74c..aa132e6c0 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -72,6 +72,7 @@ "target": {"type": "string"}, "preventDefault": {"type": "boolean"}, "stopPropagation": {"type": "boolean"}, + "debounce": {"type": "integer", "minimum": 0}, }, "required": ["target"], }, diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 6a692eb16..3e3143c0d 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -915,6 +915,7 @@ class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool + debounce: NotRequired[int] class JsonImportSource(TypedDict): @@ -939,6 +940,7 @@ class BaseEventHandler: __slots__ = ( "__weakref__", + "debounce", "function", "prevent_default", "stop_propagation", @@ -954,6 +956,9 @@ class BaseEventHandler: stop_propagation: bool """Stops the default action associate with the event from taking place.""" + debounce: int | None + """How long, in milliseconds, client-side user input state should be preserved.""" + target: str | None """Typically left as ``None`` except when a static target is useful. @@ -1124,6 +1129,8 @@ class Event(dict): A light `dict` wrapper for event data passed to event handler functions. """ + debounce: int | None + def __getattr__(self, name: str) -> Any: value = self.get(name) return Event(value) if isinstance(value, dict) else value diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 7fd857b31..c26d760fc 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -20,7 +20,7 @@ def test_event_handler_repr(): handler = EventHandler(lambda: None) assert repr(handler) == ( f"EventHandler(function={handler.function}, prevent_default=False, " - f"stop_propagation=False, target={handler.target!r})" + f"stop_propagation=False, debounce=None, target={handler.target!r})" ) @@ -28,23 +28,33 @@ def test_event_handler_props(): handler_0 = EventHandler(lambda data: None) assert handler_0.stop_propagation is False assert handler_0.prevent_default is False + assert handler_0.debounce is None assert handler_0.target is None handler_1 = EventHandler(lambda data: None, prevent_default=True) assert handler_1.stop_propagation is False assert handler_1.prevent_default is True + assert handler_1.debounce is None assert handler_1.target is None handler_2 = EventHandler(lambda data: None, stop_propagation=True) assert handler_2.stop_propagation is True assert handler_2.prevent_default is False + assert handler_2.debounce is None assert handler_2.target is None handler_3 = EventHandler(lambda data: None, target="123") assert handler_3.stop_propagation is False assert handler_3.prevent_default is False + assert handler_3.debounce is None assert handler_3.target == "123" + handler_4 = EventHandler(lambda data: None, debounce=250) + assert handler_4.stop_propagation is False + assert handler_4.prevent_default is False + assert handler_4.debounce == 250 + assert handler_4.target is None + def test_event_handler_equivalence(): async def func(data): @@ -62,6 +72,8 @@ async def func(data): func, prevent_default=False ) + assert EventHandler(func, debounce=200) != EventHandler(func, debounce=100) + assert EventHandler(func, target="123") != EventHandler(func, target="456") @@ -97,6 +109,7 @@ async def test_merge_event_handler_empty_list(): [ ({"stop_propagation": True}, {"stop_propagation": False}), ({"prevent_default": True}, {"prevent_default": False}), + ({"debounce": 200}, {"debounce": 100}), ({"target": "this"}, {"target": "that"}), ], ) @@ -338,6 +351,24 @@ def handler(event: Event): assert eh.stop_propagation is True +def test_detect_debounce(): + def handler(event: Event): + event.debounce = 200 + + eh = EventHandler(handler) + assert eh.debounce == 200 + + +def test_computed_debounce_value_is_not_detected(): + computed_debounce = 200 + + def handler(event: Event): + event.debounce = computed_debounce + + eh = EventHandler(handler) + assert eh.debounce is None + + def test_detect_both(): def handler(event: Event): event.preventDefault() @@ -358,6 +389,14 @@ def handler(event: Event, *, extra_param): assert eh.stop_propagation is True +def test_detect_debounce_when_handler_is_partial(): + def handler(event: Event, *, extra_param): + event.debounce = 125 + + eh = EventHandler(partial(handler, extra_param=125)) + assert eh.debounce == 125 + + def test_no_detect(): def handler(event: Event): pass @@ -365,6 +404,7 @@ def handler(event: Event): eh = EventHandler(handler) assert eh.prevent_default is False assert eh.stop_propagation is False + assert eh.debounce is None def test_event_wrapper(): @@ -392,6 +432,20 @@ def handler(event: Event): assert handler.prevent_default is True +async def test_vdom_has_debounce(): + @component + def MyComponent(): + def handler(event: Event): + event.debounce = 200 + + return html.input({"onChange": handler}) + + async with Layout(MyComponent()) as layout: + await layout.render() + handler = next(iter(layout._event_handlers.values())) + assert handler.debounce == 200 + + def test_event_export(): from reactpy.types import Event @@ -404,20 +458,24 @@ def handler(event: Event): other = Event() other.preventDefault() other.stopPropagation() + other.debounce = 200 eh = EventHandler(handler) assert eh.prevent_default is False assert eh.stop_propagation is False + assert eh.debounce is None def test_detect_renamed_argument(): def handler(e: Event): e.preventDefault() e.stopPropagation() + e.debounce = 200 eh = EventHandler(handler) assert eh.prevent_default is True assert eh.stop_propagation is True + assert eh.debounce == 200 async def test_event_queue_sequential_processing(display: DisplayFixture): @@ -622,3 +680,31 @@ def on_change(event): # Check the final value assert (await inp.evaluate("node => node.value")) == target_text + + +async def test_controlled_input_respects_custom_debounce(display: DisplayFixture): + @reactpy.component + def ControlledInput(): + value, set_value = use_state("") + + def on_change(event: Event): + event.debounce = 0 + set_value(event.target.value.upper()) + + return reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + } + ) + + await display.show(ControlledInput) + + inp = await display.page.wait_for_selector("#controlled-input") + await inp.type("a", delay=0) + + await display.page.wait_for_function( + "() => document.getElementById('controlled-input')?.value === 'A'" + ) + assert (await inp.evaluate("node => node.value")) == "A" diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index c1436f1d7..b00ca7a34 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -158,6 +158,15 @@ def test_nested_html_access_raises_error(): "stopPropagation": True, }, }, + { + "tagName": "div", + "eventHandlers": { + "onEvent": { + "target": "something", + "debounce": 200, + } + }, + }, { "tagName": "div", "importSource": {"source": "something"}, @@ -270,6 +279,18 @@ def test_valid_vdom(value): }, r"data\.eventHandlers\.onEvent\.stopPropagation must be boolean", ), + ( + { + "tagName": "tag", + "eventHandlers": { + "onEvent": { + "target": "something", + "debounce": None, + } + }, + }, + r"data\.eventHandlers\.onEvent\.debounce must be integer", + ), ( {"tagName": "tag", "importSource": None}, r"data\.importSource must be object", From 77bcc0b7aba1811e3c7d103a263cc6423e219b3b Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 13 Apr 2026 21:50:30 -0400 Subject: [PATCH 18/31] fix formatting and type hint --- .../packages/@reactpy/client/src/components.tsx | 5 ++++- src/js/packages/@reactpy/client/src/vdom.tsx | 7 ++++++- src/reactpy/executors/asgi/middleware.py | 15 ++++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 3bc97242b..0b4e29e37 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -25,7 +25,10 @@ type ReactPyInputHandler = ((event: TargetedEvent) => void) & { isHandler?: boolean; }; -type UserInputTarget = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; +type UserInputTarget = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement; function trackUserInput( event: TargetedEvent, diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index ccb41ad23..b2d197e01 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -206,7 +206,12 @@ export function createAttributes( function createEventHandler( client: ReactPyClient, name: string, - { target, preventDefault, stopPropagation, debounce }: ReactPyVdomEventHandler, + { + target, + preventDefault, + stopPropagation, + debounce, + }: ReactPyVdomEventHandler, ): [string, () => void] { const eventHandler = function (...args: any[]) { const data = Array.from(args).map((value) => { diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 6d3505998..13183d4d3 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -8,10 +8,11 @@ from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Any, Unpack +from typing import Any, Unpack, cast import orjson from asgi_tools import ResponseText, ResponseWebSocket +from asgiref import typing as asgi_types from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI @@ -265,7 +266,11 @@ async def __call__( prefix=self.parent.static_path, ) - await self._static_file_server(scope, receive, send) + await self._static_file_server( + cast(asgi_types.Scope, scope), + cast(asgi_types.ASGIReceiveCallable, receive), + cast(asgi_types.ASGISendCallable, send), + ) @dataclass @@ -285,7 +290,11 @@ async def __call__( autorefresh=True, ) - await self._static_file_server(scope, receive, send) + await self._static_file_server( + cast(asgi_types.Scope, scope), + cast(asgi_types.ASGIReceiveCallable, receive), + cast(asgi_types.ASGISendCallable, send), + ) class Error404App: From 5148a1c499b9cceec990bfa562aefb89fdc6bb58 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 00:58:16 -0400 Subject: [PATCH 19/31] Fix flakey tests --- src/reactpy/core/events.py | 8 +++++++- src/reactpy/reactjs/utils.py | 8 +++++++- tests/test_asgi/test_pyscript.py | 10 +++++++--- tests/test_pyscript/test_components.py | 4 +++- .../test_reactjs/js_fixtures/nest-custom-under-web.js | 10 ++++++---- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 09a017e0c..d3c080fe8 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -147,7 +147,13 @@ def __eq__(self, other: object) -> bool: ) def __repr__(self) -> str: - public_names = [name for name in self.__slots__ if not name.startswith("_")] + public_names = ( + "function", + "prevent_default", + "stop_propagation", + "debounce", + "target", + ) items = ", ".join([f"{n}={getattr(self, n)!r}" for n in public_names]) return f"{type(self).__name__}({items})" diff --git a/src/reactpy/reactjs/utils.py b/src/reactpy/reactjs/utils.py index 7c1331cd6..aee317b19 100644 --- a/src/reactpy/reactjs/utils.py +++ b/src/reactpy/reactjs/utils.py @@ -162,7 +162,13 @@ def copy_file(target: Path, source: Path, symlink: bool) -> None: if symlink: if target.exists(): target.unlink() - target.symlink_to(source) + try: + target.symlink_to(source) + except OSError as error: + try: + os.link(source, target) + except OSError as e: + raise error from e else: temp_target = target.with_suffix(f"{target.suffix}.tmp") shutil.copy(source, temp_target) diff --git a/tests/test_asgi/test_pyscript.py b/tests/test_asgi/test_pyscript.py index 9a86a9592..24608d301 100644 --- a/tests/test_asgi/test_pyscript.py +++ b/tests/test_asgi/test_pyscript.py @@ -23,7 +23,7 @@ async def display(browser): async with BackendFixture(app) as server: async with DisplayFixture( - backend=server, browser=browser, timeout=20 + backend=server, browser=browser, timeout=30 ) as new_display: yield new_display @@ -38,7 +38,9 @@ async def multi_file_display(browser): ) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, browser=browser) as new_display: + async with DisplayFixture( + backend=server, browser=browser, timeout=30 + ) as new_display: yield new_display @@ -58,7 +60,9 @@ async def homepage(request): app = Starlette(routes=[Route("/", homepage)]) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, browser=browser) as new_display: + async with DisplayFixture( + backend=server, browser=browser, timeout=30 + ) as new_display: yield new_display diff --git a/tests/test_pyscript/test_components.py b/tests/test_pyscript/test_components.py index 6d1080a57..dac7de113 100644 --- a/tests/test_pyscript/test_components.py +++ b/tests/test_pyscript/test_components.py @@ -15,7 +15,9 @@ async def display(browser): app = ReactPy(root_hotswap_component, pyscript_setup=True) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, browser=browser) as new_display: + async with DisplayFixture( + backend=server, browser=browser, timeout=30 + ) as new_display: yield new_display diff --git a/tests/test_reactjs/js_fixtures/nest-custom-under-web.js b/tests/test_reactjs/js_fixtures/nest-custom-under-web.js index 7718e4f3e..0f5d73160 100644 --- a/tests/test_reactjs/js_fixtures/nest-custom-under-web.js +++ b/tests/test_reactjs/js_fixtures/nest-custom-under-web.js @@ -1,7 +1,9 @@ -import React from "https://esm.sh/v135/react@19.0" -import ReactDOM from "https://esm.sh/v135/react-dom@19.0/client" -import {Container} from "https://esm.sh/v135/react-bootstrap@2.10.10/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Container"; -export {Container}; +import React from "react" +import ReactDOM from "react-dom/client" + +export function Container({ children }) { + return React.createElement("div", { id: "container" }, children) +} export function bind(node, config) { const root = ReactDOM.createRoot(node); From 8b0c95d9c59a157ede126425cfb3e886239f0d31 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 11:07:10 -0400 Subject: [PATCH 20/31] expose max_queue_size within ReactPyConfig --- src/reactpy/types.py | 1 + tests/test_asgi/test_utils.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 3e3143c0d..1d9b237d9 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1101,6 +1101,7 @@ class ReactPyConfig(TypedDict, total=False): reconnect_backoff_multiplier: float async_rendering: bool debug: bool + max_queue_size: int tests_default_timeout: int diff --git a/tests/test_asgi/test_utils.py b/tests/test_asgi/test_utils.py index 369283dce..4f8b5c207 100644 --- a/tests/test_asgi/test_utils.py +++ b/tests/test_asgi/test_utils.py @@ -14,6 +14,8 @@ def test_process_settings(): assert config.REACTPY_ASYNC_RENDERING.current is False utils.process_settings({"async_rendering": True}) assert config.REACTPY_ASYNC_RENDERING.current is True + utils.process_settings({"max_queue_size": 10}) + assert config.REACTPY_MAX_QUEUE_SIZE.current == 10 def test_invalid_setting(): From 69b665faf5b1df950e0587e3b6706f3989a86f47 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 11:32:38 -0400 Subject: [PATCH 21/31] queue backpressure --- src/reactpy/core/layout.py | 25 ++++++++++++++++++++++++- tests/test_core/test_layout.py | 24 ++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 0f9cbe5fa..dce82f690 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -99,6 +99,7 @@ async def __aexit__( await t await self._unmount_model_states([root_model_state]) + await self._rendering_queue.close() # delete attributes here to avoid access after exiting context manager del self._event_handlers @@ -773,17 +774,39 @@ def __init__(self) -> None: self._loop = get_running_loop() self._queue: Queue[_Type] = Queue(REACTPY_MAX_QUEUE_SIZE.current) self._pending: set[_Type] = set() + self._put_tasks: dict[_Type, Task[None]] = {} def put(self, value: _Type) -> None: if value not in self._pending: self._pending.add(value) - self._loop.call_soon_threadsafe(self._queue.put_nowait, value) + self._loop.call_soon_threadsafe(self._schedule_put, value) + + def _schedule_put(self, value: _Type) -> None: + self._put_tasks[value] = create_task(self._put_with_backpressure(value)) + + async def _put_with_backpressure(self, value: _Type) -> None: + try: + await self._queue.put(value) + except BaseException: + self._pending.discard(value) + raise + finally: + self._put_tasks.pop(value, None) async def get(self) -> _Type: value = await self._queue.get() self._pending.remove(value) return value + async def close(self) -> None: + for task in list(self._put_tasks.values()): + task.cancel() + for task in list(self._put_tasks.values()): + with suppress(CancelledError): + await task + self._put_tasks.clear() + self._pending.clear() + def _get_children_info( children: list[VdomChild], diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index a3f917699..645d96d5a 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -11,11 +11,15 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG +from reactpy.config import ( + REACTPY_ASYNC_RENDERING, + REACTPY_DEBUG, + REACTPY_MAX_QUEUE_SIZE, +) from reactpy.core.component import component from reactpy.core.events import EventHandler from reactpy.core.hooks import use_async_effect, use_effect, use_state -from reactpy.core.layout import Layout +from reactpy.core.layout import Layout, _ThreadSafeQueue from reactpy.testing import ( HookCatcher, StaticEventHandler, @@ -102,6 +106,22 @@ def SimpleComponent(): ) +async def test_thread_safe_queue_applies_backpressure(): + with patch.object(REACTPY_MAX_QUEUE_SIZE, "current", 1): + queue = _ThreadSafeQueue[int]() + + queue.put(1) + queue.put(2) + + await asyncio.sleep(0) + assert await asyncio.wait_for(queue.get(), 1) == 1 + + await asyncio.sleep(0) + assert await asyncio.wait_for(queue.get(), 1) == 2 + + await queue.close() + + async def test_nested_component_layout(): parent_set_state = reactpy.Ref(None) child_set_state = reactpy.Ref(None) From db914fd7f5a46147b922c11c4a03cb1a927da14c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 11:46:40 -0400 Subject: [PATCH 22/31] clean up http path/qs handling --- src/js/packages/@reactpy/client/src/mount.tsx | 7 +-- .../packages/@reactpy/client/src/websocket.ts | 16 +++++- src/reactpy/executors/asgi/middleware.py | 16 ++++-- tests/test_asgi/test_standalone.py | 55 +++++++++++++++++++ 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 281f8291e..df4288101 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -12,12 +12,9 @@ export function mountReactPy(props: MountProps) { ); // Embed the initial HTTP path into the WebSocket URL - componentUrl.searchParams.append("http_pathname", window.location.pathname); + componentUrl.searchParams.append("path", window.location.pathname); if (window.location.search) { - componentUrl.searchParams.append( - "http_query_string", - window.location.search, - ); + componentUrl.searchParams.append("qs", window.location.search); } // Configure a new ReactPy client diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts index b88af5176..159b59e4c 100644 --- a/src/js/packages/@reactpy/client/src/websocket.ts +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -1,6 +1,19 @@ import type { CreateReconnectingWebSocketProps } from "./types"; import log from "./logger"; +function syncBrowserLocation(url: URL): void { + // The window will always have a HTTP path, so ReactPy should always be aware of it. + url.searchParams.set("path", window.location.pathname); + + if (window.location.search) { + // Set the query string parameter if the HTTP location has a query string. + url.searchParams.set("qs", window.location.search); + } else { + // Remove any existing (potentially stale) query string parameter if the current location doesn't have one + url.searchParams.delete("qs"); + } +} + export function createReconnectingWebSocket( props: CreateReconnectingWebSocketProps, ) { @@ -15,8 +28,7 @@ export function createReconnectingWebSocket( if (closed) { return; } - props.url.searchParams.set("path", window.location.pathname); - props.url.searchParams.set("qs", window.location.search); + syncBrowserLocation(props.url); socket.current = new WebSocket(props.url); socket.current.onopen = () => { everConnected = true; diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 13183d4d3..e09dee6b6 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -42,6 +42,14 @@ _logger = logging.getLogger(__name__) +def _location_from_websocket_query_string(query_string: str) -> Location: + ws_query_string = urllib.parse.parse_qs(query_string, strict_parsing=True) + return Location( + path=ws_query_string.get("path", [""])[0], + query_string=ws_query_string.get("qs", [""])[0], + ) + + class ReactPyMiddleware: root_component: RootComponentConstructor | None = None root_components: dict[str, RootComponentConstructor] @@ -221,14 +229,10 @@ async def run_dispatcher(self) -> None: raise RuntimeError("No root component provided.") # Create a connection object by analyzing the websocket's query string. - ws_query_string = urllib.parse.parse_qs( - self.scope["query_string"].decode(), strict_parsing=True - ) connection = Connection( scope=self.scope, # type: ignore - location=Location( - path=ws_query_string.get("http_pathname", [""])[0], - query_string=ws_query_string.get("http_query_string", [""])[0], + location=_location_from_websocket_query_string( + self.scope["query_string"].decode() ), carrier=self, ) diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 69fbf9f7d..5de5cc91e 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -9,6 +9,7 @@ import reactpy from reactpy import html from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT +from reactpy.executors.asgi.middleware import _location_from_websocket_query_string from reactpy.executors.asgi.standalone import ReactPy from reactpy.testing import BackendFixture, DisplayFixture, poll from reactpy.types import Connection, Location @@ -107,6 +108,60 @@ def ShowRoute(): await poll_location.until_equals(loc) +async def test_use_location_after_reconnect_from_client_navigation( + display: DisplayFixture, +): + location = reactpy.Ref() + + @poll + async def poll_location(): + return getattr(location, "current", None) + + @reactpy.component + def ShowRoute(): + location.current = reactpy.use_location() + return html.pre(str(location.current)) + + await display.page.add_init_script( + """ + (() => { + window.__reactpySockets = []; + const NativeWebSocket = window.WebSocket; + window.WebSocket = class extends NativeWebSocket { + constructor(url, protocols) { + super(url, protocols); + window.__reactpySockets.push(this); + } + }; + })(); + """ + ) + + await display.show(ShowRoute) + await poll_location.until_equals(Location("/", "")) + + await display.page.evaluate( + """ + () => { + history.pushState({}, "", "/client-route?view=next"); + const socket = window.__reactpySockets.at(-1); + if (!socket) { + throw new Error("Missing ReactPy websocket"); + } + socket.close(); + } + """ + ) + + await poll_location.until_equals(Location("/client-route", "?view=next")) + + +def test_location_from_websocket_query_string_uses_path_and_qs(): + assert _location_from_websocket_query_string( + "path=%2Fcurrent&qs=%3Fview%3Dnext" + ) == Location("/current", "?view=next") + + async def test_carrier(display: DisplayFixture): hook_val = reactpy.Ref() From cc431df2b68390a81afb55b56747a0a2956535ee Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 11:50:05 -0400 Subject: [PATCH 23/31] Add test to ensure client input value takes priority. --- tests/test_core/test_events.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index c26d760fc..390b713de 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -708,3 +708,31 @@ def on_change(event: Event): "() => document.getElementById('controlled-input')?.value === 'A'" ) assert (await inp.evaluate("node => node.value")) == "A" + + +async def test_controlled_input_default_debounce_prefers_latest_client_value( + display: DisplayFixture, +): + """Prefer the latest client value for a controlled input when using debounce, even if the server is still processing an older event.""" + @reactpy.component + def ControlledInput(): + value, set_value = use_state("") + + def on_change(event: Event): + set_value(event.target.value.upper()) + + return reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + } + ) + + await display.show(ControlledInput) + + inp = await display.page.wait_for_selector("#controlled-input") + await inp.type("a", delay=0) + + await asyncio.sleep(0.5) + assert (await inp.evaluate("node => node.value")) == "a" From 54526b90004def5c370db9a039a938928b1ec97e Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 12:11:47 -0400 Subject: [PATCH 24/31] More robust async event shielding --- src/reactpy/core/hooks.py | 12 +++++++++--- tests/test_core/test_hooks.py | 9 ++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index b986c60ae..f7bd718e8 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -231,9 +231,13 @@ def decorator(func: _AsyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: # Make sure we always clean up the previous effect's resources if pending_task.current: - pending_task.current.cancel() + previous_task = pending_task.current + if not shield: + previous_task.cancel() with contextlib.suppress(asyncio.CancelledError): - await pending_task.current + await previous_task + if pending_task.current is previous_task: + pending_task.current = None run_effect_cleanup(cleanup_func) @@ -267,7 +271,9 @@ async def effect(stop: asyncio.Event) -> None: if not shield: task.cancel() with contextlib.suppress(asyncio.CancelledError): - await task + cleanup_func.current = await task + if pending_task.current is task: + pending_task.current = None # Run the clean-up function when the effect is stopped, # if it hasn't been run already by a new effect diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index ae509c161..6856ae187 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -609,6 +609,7 @@ async def effect(): async def test_use_async_effect_shield(): component_hook = HookCatcher() effect_ran = asyncio.Event() + effect_was_cancelled = asyncio.Event() effect_finished = asyncio.Event() stop_waiting = asyncio.Event() @@ -618,7 +619,11 @@ def ComponentWithShieldedEffect(): @reactpy.hooks.use_async_effect(dependencies=None, shield=True) async def effect(): effect_ran.set() - await stop_waiting.wait() + try: + await stop_waiting.wait() + except asyncio.CancelledError: + effect_was_cancelled.set() + raise effect_finished.set() return reactpy.html.div() @@ -636,6 +641,7 @@ async def effect(): # Verify effect hasn't finished yet but also wasn't cancelled assert not effect_finished.is_set() + assert not effect_was_cancelled.is_set() # Now allow the effect to finish stop_waiting.set() @@ -644,6 +650,7 @@ async def effect(): await layout.render() await asyncio.wait_for(effect_finished.wait(), 1) + assert not effect_was_cancelled.is_set() async def test_async_effect_sleep_is_cancelled_on_re_render(): From 8640a690c560bab92aa8f23560790b42b2e5bc1a Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 15:22:40 -0400 Subject: [PATCH 25/31] self review --- src/reactpy/core/hooks.py | 7 +++++-- tests/test_core/test_events.py | 1 + tests/test_reactjs/test_utils.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index f7bd718e8..5aacbf5d4 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -215,8 +215,11 @@ def use_async_effect( shield: If ``True``, the effect will not be cancelled when the hook is running clean-up. This can be useful if you want to ensure that the effect runs to completion even if the - component is unmounted while the effect is still running (e.g. a database query). - However, use this option with caution as it can lead to memory leaks. + component is unmounted while the effect is still running (e.g. a multi-step database query). + + Use this option with caution as it can lead to memory leaks if a faulty effect + stays alive indefinitely. If using this option, it is highly suggested to implement + your own timeout within the effect to mitigate this risk. Returns: If not function is provided, a decorator. Otherwise ``None``. diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 390b713de..c1609ca54 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -714,6 +714,7 @@ async def test_controlled_input_default_debounce_prefers_latest_client_value( display: DisplayFixture, ): """Prefer the latest client value for a controlled input when using debounce, even if the server is still processing an older event.""" + @reactpy.component def ControlledInput(): value, set_value = use_state("") diff --git a/tests/test_reactjs/test_utils.py b/tests/test_reactjs/test_utils.py index ecf746af0..a1b2ca23a 100644 --- a/tests/test_reactjs/test_utils.py +++ b/tests/test_reactjs/test_utils.py @@ -190,6 +190,38 @@ def test_copy_file_fallback(tmp_path): mock_rename.assert_called_once() +def test_copy_file_symlink_falls_back_to_hard_link(tmp_path): + source = tmp_path / "source.txt" + source.write_text("content") + target = tmp_path / "target.txt" + + path_cls = type(target) + + with patch.object(path_cls, "symlink_to", side_effect=OSError): + with patch("os.link") as mock_link: + copy_file(target, source, symlink=True) + + mock_link.assert_called_once_with(source, target) + + +def test_copy_file_symlink_fallback_reraises_original_error(tmp_path): + source = tmp_path / "source.txt" + source.write_text("content") + target = tmp_path / "target.txt" + + path_cls = type(target) + symlink_error = OSError("symlink failed") + hard_link_error = OSError("hard link failed") + + with patch.object(path_cls, "symlink_to", side_effect=symlink_error): + with patch("os.link", side_effect=hard_link_error): + with pytest.raises(OSError) as exc_info: + copy_file(target, source, symlink=True) + + assert exc_info.value is symlink_error + assert exc_info.value.__cause__ is hard_link_error + + def test_simple_file_lock_timeout(tmp_path): lock_file = tmp_path / "lock" From 02a2ab70bf138f0415da1311835757261c95177f Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 14 Apr 2026 15:38:11 -0400 Subject: [PATCH 26/31] Add coverage test --- CHANGELOG.md | 2 +- tests/test_core/test_layout.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b98f6393..09b81c68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,7 @@ Don't forget to remove deprecated code on each major release! - Fixed a bug where script elements would not render to the DOM as plain text. - Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components. - Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads. -- Fixed a bug where events (server to client, and client to server) could be lost during rapid actions. +- Fixed a bug where events on controlled inputs (e.g. `html.input({"onChange": ...})`) could be lost during rapid actions. - Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy. ## [1.1.0] - 2024-11-24 diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 645d96d5a..48e06a153 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -122,6 +122,23 @@ async def test_thread_safe_queue_applies_backpressure(): await queue.close() +async def test_thread_safe_queue_close_cancels_pending_puts(): + with patch.object(REACTPY_MAX_QUEUE_SIZE, "current", 1): + queue = _ThreadSafeQueue[int]() + + await queue._queue.put(1) + queue._pending.add(2) + task = asyncio.create_task(queue._put_with_backpressure(2)) + queue._put_tasks[2] = task + + await asyncio.sleep(0) + await queue.close() + + assert task.cancelled() + assert queue._put_tasks == {} + assert queue._pending == set() + + async def test_nested_component_layout(): parent_set_state = reactpy.Ref(None) child_set_state = reactpy.Ref(None) From dcd5ff9c589f4c977d810ee814045a6d06813627 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 16 Apr 2026 22:11:38 -0400 Subject: [PATCH 27/31] Improve pyscript wheel generation --- .gitignore | 1 + pyproject.toml | 4 +- src/build_scripts/build_local_wheel.py | 106 +++ src/reactpy/executors/asgi/middleware.py | 1 + src/reactpy/executors/pyscript/utils.py | 366 ++++++++--- src/reactpy/types.py | 2 +- tests/test_asgi/test_middleware.py | 33 + tests/test_pyscript/test_utils.py | 782 +++++++++++++++++++++-- 8 files changed, 1180 insertions(+), 115 deletions(-) create mode 100644 src/build_scripts/build_local_wheel.py diff --git a/.gitignore b/.gitignore index 92534dae0..3e4fd10f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ src/reactpy/static/*.js* src/reactpy/static/morphdom/ src/reactpy/static/pyscript/ +src/reactpy/static/wheels/ src/js/**/*.tgz src/js/**/LICENSE diff --git a/pyproject.toml b/pyproject.toml index 6ba2f2f83..a5e43bffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,8 +71,8 @@ installer = "uv" reactpy = "reactpy._console.cli:entry_point" [[tool.hatch.build.hooks.build-scripts.scripts]] -commands = [] -artifacts = [] +commands = ['python "src/build_scripts/build_local_wheel.py"'] +artifacts = ["src/reactpy/static/wheels/*.whl"] ############################# # >>> Hatch Test Runner <<< # diff --git a/src/build_scripts/build_local_wheel.py b/src/build_scripts/build_local_wheel.py new file mode 100644 index 000000000..6c365be69 --- /dev/null +++ b/src/build_scripts/build_local_wheel.py @@ -0,0 +1,106 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +from pathlib import Path + +_logger = logging.getLogger(__name__) +_SKIP_ENV_VAR = "REACTPY_SKIP_LOCAL_WHEEL_BUILD" + + +def _reactpy_version(root_dir: Path) -> str: + init_file = root_dir / "src" / "reactpy" / "__init__.py" + if match := re.search( + r'^__version__ = "([^"]+)"$', + init_file.read_text(encoding="utf-8"), + re.MULTILINE, + ): + return match.group(1) + raise RuntimeError("Could not determine the current ReactPy version.") + + +def _matching_reactpy_wheel(dist_dir: Path, version: str) -> Path | None: + matching_wheels = sorted( + dist_dir.glob(f"reactpy-{version}-*.whl"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + return matching_wheels[0] if matching_wheels else None + + +def _hatch_build_command(root_dir: Path) -> list[str] | None: + for candidate in ( + root_dir / ".venv" / "Scripts" / "hatch.exe", + root_dir / ".venv" / "bin" / "hatch", + ): + if candidate.exists(): + return [str(candidate), "build", "-t", "wheel"] + + if hatch_command := shutil.which("hatch"): + return [hatch_command, "build", "-t", "wheel"] + + return None + + +def main() -> int: + if os.environ.get(_SKIP_ENV_VAR): + print("Skipping local ReactPy wheel build.") # noqa: T201 + return 0 + + root_dir = Path(__file__).parent.parent.parent + version = _reactpy_version(root_dir) + static_wheels_dir = root_dir / "src" / "reactpy" / "static" / "wheels" + dist_dir = root_dir / "dist" + hatch_build_command = _hatch_build_command(root_dir) + + if not hatch_build_command: + _logger.error("Could not locate Hatch while building the embedded wheel.") + return 1 + + static_wheels_dir.mkdir(parents=True, exist_ok=True) + for wheel_file in static_wheels_dir.glob("reactpy-*.whl"): + wheel_file.unlink() + + env = os.environ.copy() + env[_SKIP_ENV_VAR] = "1" + for key in tuple(env): + if key.startswith("HATCH_ENV_"): + env.pop(key) + + result = subprocess.run( # noqa: S603 + hatch_build_command, + capture_output=True, + text=True, + check=False, + cwd=root_dir, + env=env, + ) + + if result.returncode != 0: + _logger.error( + "Failed to build the embedded ReactPy wheel.\nstdout:\n%s\nstderr:\n%s", + result.stdout, + result.stderr, + ) + return result.returncode + + built_wheel = _matching_reactpy_wheel(dist_dir, version) + if not built_wheel: + _logger.error("Failed to locate the newly built ReactPy wheel in %s", dist_dir) + return 1 + + shutil.copy2(built_wheel, static_wheels_dir / built_wheel.name) + print(f"Embedded local ReactPy wheel at '{static_wheels_dir / built_wheel.name}'") # noqa: T201 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index e09dee6b6..d6e81a324 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -268,6 +268,7 @@ async def __call__( Error404App(), root=self.parent.static_dir, prefix=self.parent.static_path, + autorefresh=True, ) await self._static_file_server( diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index 0c8eeeb1a..60dc71df3 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -1,22 +1,29 @@ -# ruff: noqa: S607 +# ruff: noqa: S603 from __future__ import annotations +import base64 +import csv import functools +import hashlib +import importlib.util import json +import os import re import shutil import subprocess +import sys import textwrap from collections.abc import Callable -from glob import glob +from importlib import metadata +from io import StringIO from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib import request from uuid import uuid4 +from zipfile import ZIP_DEFLATED, ZipFile import reactpy -from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR +from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX from reactpy.types import VdomDict from reactpy.utils import reactpy_to_string @@ -25,6 +32,9 @@ _logger = getLogger(__name__) +_PYSCRIPT_WHEELS_DIR = "wheels" +_WHEEL_FILENAME_PART_COUNT = 5 + def minify_python(source: str) -> str: """Minify Python source code.""" @@ -149,93 +159,297 @@ def extend_pyscript_config( return json.dumps(pyscript_config) -def reactpy_version_string() -> str: # nocov - from reactpy.testing.common import GITHUB_ACTIONS +def reactpy_version_string() -> str: + wheel_file = _ensure_local_reactpy_wheel() + return ( + f"{REACTPY_PATH_PREFIX.current}static/{_PYSCRIPT_WHEELS_DIR}/{wheel_file.name}" + ) - # Get a list of all versions via `pip index versions` - result = get_reactpy_versions() - local_version = reactpy.__version__ - # Check if the command failed - if not result: - _logger.warning( - "Failed to verify what versions of ReactPy exist on PyPi. " - "PyScript functionality may not work as expected.", - ) - return f"reactpy=={local_version}" +def _ensure_local_reactpy_wheel() -> Path: + packaged_wheel = _find_current_reactpy_wheel(_packaged_reactpy_wheels_dir()) - # Have `pip` tell us what versions are available - known_versions: list[str] = result.get("versions", []) - latest_version: str = result.get("latest", "") + if _source_checkout_exists(): + if packaged_wheel and not _wheel_is_stale_for_source(packaged_wheel): + return packaged_wheel - # Return early if the version is available on PyPi and we're not in a CI environment - if local_version in known_versions and not GITHUB_ACTIONS: - return f"reactpy=={local_version}" + if built_wheel := _build_reactpy_wheel_from_source(): + return _copy_reactpy_wheel_to_static_dir(built_wheel) - # We are now determining an alternative method of installing ReactPy for PyScript - if not GITHUB_ACTIONS: - _logger.warning( - "Your ReactPy version isn't available on PyPi. " - "Attempting to find an alternative installation method for PyScript...", + raise RuntimeError( + "ReactPy could not build a local wheel for PyScript. " + "Ensure Hatch is installed and `hatch build -t wheel` succeeds." ) - # Build a local wheel for ReactPy, if needed - dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist" - wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) - if not wheel_glob: - _logger.warning("Attempting to build a local wheel for ReactPy...") - subprocess.run( - ["hatch", "build", "-t", "wheel"], + if packaged_wheel: + return packaged_wheel + + if rebuilt_wheel := _rebuild_installed_reactpy_wheel(): + return rebuilt_wheel + + raise RuntimeError( + "ReactPy could not locate or reconstruct a local wheel for PyScript." + ) + + +def _source_checkout_exists() -> bool: + return (_reactpy_repo_root() / "pyproject.toml").exists() + + +def _reactpy_repo_root() -> Path: + return Path(reactpy.__file__).resolve().parent.parent.parent + + +def _packaged_reactpy_wheels_dir() -> Path: + return Path(reactpy.__file__).resolve().parent / "static" / _PYSCRIPT_WHEELS_DIR + + +def _find_current_reactpy_wheel(directory: Path) -> Path | None: + if not directory.exists(): + return None + + matches = sorted( + path + for path in directory.glob("reactpy-*.whl") + if _wheel_matches_local_version(path) + ) + return matches[0] if matches else None + + +def _wheel_matches_local_version(path: Path) -> bool: + name_parts = path.name.removesuffix(".whl").split("-") + return ( + len(name_parts) >= _WHEEL_FILENAME_PART_COUNT + and name_parts[0].replace("_", "-").lower() == "reactpy" + and _normalize_wheel_part(name_parts[1]) + == _normalize_wheel_part(reactpy.__version__) + ) + + +def _normalize_wheel_part(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def _wheel_is_stale_for_source(wheel_file: Path) -> bool: + wheel_mtime = wheel_file.stat().st_mtime + repo_root = _reactpy_repo_root() + watched_paths = [repo_root / "pyproject.toml", repo_root / "src" / "reactpy"] + + for path in watched_paths: + if path.is_file() and path.stat().st_mtime > wheel_mtime: + return True + if path.is_dir(): + for child in path.rglob("*"): + if not child.is_file(): + continue + if child.suffix == ".pyc" or "__pycache__" in child.parts: + continue + if _packaged_reactpy_wheels_dir() in child.parents: + continue + if child.stat().st_mtime > wheel_mtime: + return True + + return False + + +def _build_reactpy_wheel_from_source() -> Path | None: + repo_root = _reactpy_repo_root() + hatch_build_command = _hatch_build_command(repo_root) + + if not hatch_build_command: + _logger.error("Could not locate Hatch while building a local ReactPy wheel.") + return None + + _logger.warning("Attempting to build a local wheel for ReactPy...") + + env = os.environ.copy() + for key in tuple(env): + if key.startswith("HATCH_ENV_"): + env.pop(key) + + try: + result = subprocess.run( + hatch_build_command, capture_output=True, text=True, check=False, - cwd=Path(reactpy.__file__).parent.parent.parent, + cwd=repo_root, + env=env, ) - wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) - - # Move the local wheel to the web modules directory, if it exists - if wheel_glob: - wheel_file = Path(wheel_glob[0]) - new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name - if not new_path.exists(): - _logger.warning( - "PyScript will utilize local wheel '%s'.", - wheel_file.name, - ) - shutil.copy(wheel_file, new_path) - return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" - - # Building a local wheel failed, try our best to give the user any version. - if latest_version: - _logger.warning( - "Failed to build a local wheel for ReactPy, likely due to missing build dependencies. " - "PyScript will default to using the latest ReactPy version on PyPi." + except OSError: + _logger.exception( + "Failed to invoke Hatch while building a local ReactPy wheel." ) - return f"reactpy=={latest_version}" - _logger.error( - "Failed to build a local wheel for ReactPy, and could not determine the latest version on PyPi. " - "PyScript functionality may not work as expected.", - ) - return f"reactpy=={local_version}" + return None + + if result.returncode != 0: + _logger.error( + "Failed to build a local ReactPy wheel.\nstdout:\n%s\nstderr:\n%s", + result.stdout, + result.stderr, + ) + return None + dist_dir = repo_root / "dist" + return _find_current_reactpy_wheel(dist_dir) -@functools.cache -def get_reactpy_versions() -> dict[Any, Any]: - """Fetches the available versions of a package from PyPI.""" + +def _hatch_build_command(repo_root: Path) -> list[str] | None: + for candidate in ( + repo_root / ".venv" / "Scripts" / "hatch.exe", + repo_root / ".venv" / "bin" / "hatch", + ): + if candidate.exists(): + return [str(candidate), "build", "-t", "wheel"] + + if hatch_command := shutil.which("hatch"): + return [hatch_command, "build", "-t", "wheel"] + + if importlib.util.find_spec("hatch") is not None: + return [sys.executable, "-m", "hatch", "build", "-t", "wheel"] + + return None + + +def _copy_reactpy_wheel_to_static_dir(wheel_file: Path) -> Path: + static_wheels_dir = _packaged_reactpy_wheels_dir() + static_wheels_dir.mkdir(parents=True, exist_ok=True) + static_wheel = static_wheels_dir / wheel_file.name + + for existing in static_wheels_dir.glob("reactpy-*.whl"): + if existing != static_wheel: + existing.unlink() + + if wheel_file.resolve() == static_wheel.resolve(): + return static_wheel + + temp_wheel = static_wheel.with_suffix(f"{static_wheel.suffix}.tmp") + shutil.copy2(wheel_file, temp_wheel) + temp_wheel.replace(static_wheel) + return static_wheel + + +def _wheel_archive_name(file_path: Path) -> str | None: + if file_path.is_absolute() or ".." in file_path.parts: + return None + + return file_path.as_posix() + + +def _rebuild_installed_reactpy_wheel() -> Path | None: try: - try: - response = request.urlopen("https://pypi.org/pypi/reactpy/json", timeout=5) - except Exception: - response = request.urlopen("http://pypi.org/pypi/reactpy/json", timeout=5) - if response.status == 200: # noqa: PLR2004 - data = json.load(response) - versions = list(data.get("releases", {}).keys()) - latest = data.get("info", {}).get("version", "") - if versions and latest: - return {"versions": versions, "latest": latest} - except Exception: - _logger.exception("Error fetching ReactPy package versions from PyPI!") - return {} + distribution = metadata.distribution("reactpy") + except metadata.PackageNotFoundError: + _logger.exception("Could not inspect the installed ReactPy distribution.") + return None + + files = distribution.files or [] + if not files: + _logger.error("The installed ReactPy distribution did not expose any files.") + return None + + static_wheels_dir = _packaged_reactpy_wheels_dir() + static_wheels_dir.mkdir(parents=True, exist_ok=True) + + wheel_path = static_wheels_dir / _installed_wheel_name(files, distribution) + temp_wheel_path = wheel_path.with_suffix(".tmp") + + record_rows: list[tuple[str, str, str]] = [] + record_name = _installed_wheel_record_name(files) + + with ZipFile(temp_wheel_path, "w", compression=ZIP_DEFLATED) as wheel_zip: + for file in files: + file_path = Path(str(file)) + archive_name = _wheel_archive_name(file_path) + if archive_name is None: + _logger.warning( + "Skipping installed path '%s' while reconstructing local ReactPy wheel.", + file_path.as_posix(), + ) + continue + + if archive_name == record_name: + continue + + absolute_path = Path(str(distribution.locate_file(file))) + if not absolute_path.is_file(): + continue + + file_data = absolute_path.read_bytes() + wheel_zip.writestr(archive_name, file_data) + record_rows.append(_record_row(archive_name, file_data)) + + record_rows.append((record_name, "", "")) + wheel_zip.writestr(record_name, _record_text(record_rows)) + + temp_wheel_path.replace(wheel_path) + _logger.warning( + "PyScript will utilize reconstructed local wheel '%s'.", wheel_path.name + ) + return wheel_path + + +def _installed_wheel_name( + files: Sequence[metadata.PackagePath], + distribution: metadata.Distribution, +) -> str: + return ( + f"reactpy-{reactpy.__version__}-{_installed_wheel_tag(files, distribution)}.whl" + ) + + +def _installed_wheel_tag( + files: Sequence[metadata.PackagePath], + distribution: metadata.Distribution, +) -> str: + wheel_file = next( + (file for file in files if Path(str(file)).name == "WHEEL"), + None, + ) + if not wheel_file: + return "py3-none-any" + + wheel_text = Path(str(distribution.locate_file(wheel_file))).read_text( + encoding="utf-8" + ) + return next( + ( + line.removeprefix("Tag: ").strip() + for line in wheel_text.splitlines() + if line.startswith("Tag: ") + ), + "py3-none-any", + ) + + +def _installed_wheel_record_name(files: Sequence[metadata.PackagePath]) -> str: + if record_file := next( + (file for file in files if Path(str(file)).name == "RECORD"), + None, + ): + return Path(str(record_file)).as_posix() + + dist_info_dir = next( + ( + Path(str(file)).parent.as_posix() + for file in files + if Path(str(file)).name == "WHEEL" + ), + f"reactpy-{reactpy.__version__}.dist-info", + ) + return f"{dist_info_dir}/RECORD" + + +def _record_row(path: str, data: bytes) -> tuple[str, str, str]: + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + return (path, f"sha256={digest.decode()}", str(len(data))) + + +def _record_text(rows: Sequence[tuple[str, str, str]]) -> str: + output = StringIO() + writer = csv.writer(output, lineterminator="\n") + writer.writerows(rows) + return output.getvalue() @functools.cache diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 1d9b237d9..d71156e1d 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -915,7 +915,7 @@ class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool - debounce: NotRequired[int] + debounce: int class JsonImportSource(TypedDict): diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index 2c0a5ec58..3642dac79 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -120,6 +120,39 @@ async def app(scope, receive, send): ... assert response.status_code == 404 +async def test_static_wheel_file_served_after_server_start(): + async def app(scope, receive, send): ... + + app = ReactPyMiddleware(app, []) + wheel_file = app.static_dir / "wheels" / "reactpy-autorefresh-test.whl" + if wheel_file.exists(): + wheel_file.unlink() + + try: + async with BackendFixture(app) as server: + url = ( + f"http://{server.host}:{server.port}" + f"{REACTPY_PATH_PREFIX.current}static/wheels/{wheel_file.name}" + ) + + response = await asyncio.to_thread( + request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + assert response.status_code == 404 + + wheel_file.parent.mkdir(parents=True, exist_ok=True) + wheel_file.write_bytes(b"local wheel") + + response = await asyncio.to_thread( + request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + assert response.status_code == 200 + assert response.content == b"local wheel" + finally: + if wheel_file.exists(): + wheel_file.unlink() + + async def test_templatetag_bad_kwargs(browser): """Override for the display fixture that uses ReactPyMiddleware.""" templates = Jinja2Templates( diff --git a/tests/test_pyscript/test_utils.py b/tests/test_pyscript/test_utils.py index 14a54ca96..f834a8a07 100644 --- a/tests/test_pyscript/test_utils.py +++ b/tests/test_pyscript/test_utils.py @@ -1,12 +1,40 @@ +import os from pathlib import Path from unittest import mock -from urllib.error import URLError from uuid import uuid4 +from zipfile import ZipFile import orjson import pytest +from reactpy.config import REACTPY_PATH_PREFIX from reactpy.executors.pyscript import utils +from reactpy.testing import assert_reactpy_did_log + + +class _FakeDistribution: + def __init__(self, root: Path, files: list[Path | str]) -> None: + self._root = root + self.files = [Path(file) for file in files] + + def locate_file(self, file: Path | str) -> Path: + return self._root / Path(str(file)) + + +def _write_file(path: Path, content: str, mtime: int | None = None) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if mtime is not None: + os.utime(path, (mtime, mtime)) + return path + + +def _current_wheel_name() -> str: + return f"reactpy-{utils.reactpy.__version__}-py3-none-any.whl" + + +def _current_wheel_path(*parts: str) -> Path: + return Path(*parts, _current_wheel_name()) if parts else Path(_current_wheel_name()) def test_bad_root_name(): @@ -18,13 +46,77 @@ def test_bad_root_name(): utils.pyscript_executor_html((file_path,), uuid4().hex, "bad") +def test_pyscript_component_html_renders_executor_markup(): + with ( + mock.patch( + "reactpy.executors.pyscript.utils.reactpy_to_string", + return_value="

initial

", + ), + mock.patch( + "reactpy.executors.pyscript.utils.pyscript_executor_html", + return_value="print('hello')", + ) as executor_html, + mock.patch( + "reactpy.executors.pyscript.utils.uuid4", + return_value=mock.Mock(hex="abc123"), + ), + ): + html = utils.pyscript_component_html( + file_paths=("app.py",), + initial={"tagName": "div"}, + root="root", + ) + + executor_html.assert_called_once_with( + file_paths=("app.py",), + uuid="abc123", + root="root", + ) + assert html == ( + '
' + "

initial

" + "
" + "" + ) + + +def test_pyscript_setup_html_renders_setup_assets(): + with ( + mock.patch.object(utils.REACTPY_DEBUG, "current", False), + mock.patch( + "reactpy.executors.pyscript.utils.extend_pyscript_config", + return_value='{"packages": []}', + ) as extend_config, + ): + html = utils.pyscript_setup_html(["foo"], {"/bar.js": "bar"}, {"x": 1}) + + extend_config.assert_called_once_with(["foo"], {"/bar.js": "bar"}, {"x": 1}) + assert ( + f'' + in html + ) + assert ( + f'' + in html + ) + assert ( + f'