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/CHANGELOG.md b/CHANGELOG.md index 70333520f..09b81c68f 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 @@ -61,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 @@ -85,6 +87,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. @@ -101,6 +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 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/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..962fe1a6f --- /dev/null +++ b/src/build_scripts/build_local_wheel.py @@ -0,0 +1,111 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +from __future__ import annotations + +import importlib.util +import logging +import os +import re +import shutil +import subprocess +import sys +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"] + + if importlib.util.find_spec("hatch") is not None: + return [sys.executable, "-m", "hatch", "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/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" } diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 52f229bae..2ce16d5fa 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -18,6 +18,37 @@ 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(); @@ -82,19 +113,64 @@ 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); + const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE); + const reconcileTimeout = useRef(null); // honor changes to value from the client via props - useEffect(() => 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); + useEffect(() => { + const reconcileValue = () => { + // If the new prop value matches what we last sent, we are in sync. + // If it differs, wait until the debounce window expires before applying it. + const elapsed = Date.now() - lastChangeTime.current; + if ( + props.value === lastUserValue.current || + elapsed >= lastInputDebounce.current + ) { + reconcileTimeout.current = null; + setValue(props.value); + return; } - // allow the client to respond (and possibly change the value) - givenOnChange(event); + + reconcileTimeout.current = window.setTimeout( + reconcileValue, + Math.max(0, lastInputDebounce.current - elapsed), + ); + }; + + reconcileValue(); + + return () => { + if (reconcileTimeout.current !== null) { + window.clearTimeout(reconcileTimeout.current); + reconcileTimeout.current = null; + } + }; + }, [props.value]); + + 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/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/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..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 }: ReactPyVdomEventHandler, + { + target, + preventDefault, + stopPropagation, + debounce, + }: ReactPyVdomEventHandler, ): [string, () => void] { const eventHandler = function (...args: any[]) { const data = Array.from(args).map((value) => { @@ -227,7 +232,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/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts index 4c72620f0..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,6 +28,7 @@ export function createReconnectingWebSocket( if (closed) { return; } + syncBrowserLocation(props.url); socket.current = new WebSocket(props.url); socket.current.onopen = () => { everConnected = true; 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", 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/_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!" + ) diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index c1dec839c..d3c080fe8 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,12 +141,19 @@ def __eq__(self, other: object) -> bool: "function", "prevent_default", "stop_propagation", + "debounce", "target", ) ) 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})" @@ -184,8 +202,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 +216,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 +237,7 @@ def merge_event_handlers( stop_propagation, prevent_default, target, + debounce, ) @@ -235,22 +260,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 +286,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/hooks.py b/src/reactpy/core/hooks.py index 454dce1ad..5aacbf5d4 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,14 @@ 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 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``. @@ -223,9 +234,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) @@ -255,9 +270,13 @@ async def effect(stop: asyncio.Event) -> None: await stop.wait() # Stop signal came first - cancel the effect task else: - task.cancel() + # Prevent task cancellation if the user enabled shielding + 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 @@ -588,9 +607,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 diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 3863213de..dce82f690 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 @@ -98,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 @@ -117,7 +119,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]) @@ -399,6 +402,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 @@ -424,6 +432,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 @@ -759,19 +772,41 @@ 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() + 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/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/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 551652fb5..d6e81a324 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 @@ -41,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] @@ -186,7 +195,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: @@ -218,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, ) @@ -261,9 +268,14 @@ async def __call__( Error404App(), root=self.parent.static_dir, prefix=self.parent.static_path, + 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), + ) @dataclass @@ -283,7 +295,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: diff --git a/src/reactpy/executors/pyscript/utils.py b/src/reactpy/executors/pyscript/utils.py index 90f1d235a..5f35515c0 100644 --- a/src/reactpy/executors/pyscript/utils.py +++ b/src/reactpy/executors/pyscript/utils.py @@ -1,21 +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 glob import glob +from collections.abc import Callable +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 @@ -24,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.""" @@ -47,15 +58,21 @@ 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 | 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 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 +127,19 @@ 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, + reactpy_pkg_string: 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"], + "packages": [ + reactpy_pkg_string or _reactpy_pkg_string(), + "jsonpointer==3.*", + "ssl", + ], "js_modules": { - "main": { + "main": modules + or { f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" } }, @@ -140,97 +164,300 @@ def extend_pyscript_config( return json.dumps(pyscript_config) -def reactpy_version_string() -> str: # nocov - from reactpy.testing.common import GITHUB_ACTIONS +def _reactpy_pkg_string() -> str: + wheel_file = _ensure_local_reactpy_wheel() + return ( + f"{REACTPY_PATH_PREFIX.current}static/{_PYSCRIPT_WHEELS_DIR}/{wheel_file.name}" + ) + - local_version = reactpy.__version__ +def _ensure_local_reactpy_wheel() -> Path: + packaged_wheel = _find_current_reactpy_wheel(_packaged_reactpy_wheels_dir()) - # Get a list of all versions via `pip index versions` - result = get_reactpy_versions() + if _source_checkout_exists(): + if packaged_wheel and not _wheel_is_stale_for_source(packaged_wheel): + return packaged_wheel - # 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.", + if built_wheel := _build_reactpy_wheel_from_source(): + return _copy_reactpy_wheel_to_static_dir(built_wheel) + + raise RuntimeError( + "ReactPy could not build a local wheel for PyScript. " + "Ensure Hatch is installed and `hatch build -t wheel` succeeds." ) - return f"reactpy=={local_version}" - # Have `pip` tell us what versions are available - known_versions: list[str] = result.get("versions", []) - latest_version: str = result.get("latest", "") + if 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 rebuilt_wheel := _rebuild_installed_reactpy_wheel(): + return rebuilt_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 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 - # 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"], + +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 -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/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/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 67439ea36..fae8aac71 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -1,5 +1,11 @@ from reactpy.testing.backend import BackendFixture -from reactpy.testing.common import GITHUB_ACTIONS, HookCatcher, StaticEventHandler, poll +from reactpy.testing.common import ( + DEFAULT_TYPE_DELAY, + GITHUB_ACTIONS, + HookCatcher, + StaticEventHandler, + poll, +) from reactpy.testing.display import DisplayFixture from reactpy.testing.logs import ( LogAssertionError, @@ -9,6 +15,7 @@ ) __all__ = [ + "DEFAULT_TYPE_DELAY", "GITHUB_ACTIONS", "BackendFixture", "DisplayFixture", 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 bcfce2ebd..63d003b4b 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -6,21 +6,28 @@ 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 str_to_bool +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 = str_to_bool(os.getenv("GITHUB_ACTIONS", "")) +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() in { + "y", + "yes", + "t", + "true", + "on", + "1", +} +DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 25 class poll(Generic[_R]): # noqa: N801 @@ -48,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) @@ -66,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""" @@ -80,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""" @@ -133,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") @@ -197,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/types.py b/src/reactpy/types.py index 6a692eb16..d71156e1d 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: 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. @@ -1096,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 @@ -1124,6 +1130,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/src/reactpy/utils.py b/src/reactpy/utils.py index 4966f9f4e..bb0bc5b3b 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -309,8 +309,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"} 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_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_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 2d4baa544..5de5cc91e 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -8,9 +8,10 @@ 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.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT 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() 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(): diff --git a/tests/test_build_local_wheel.py b/tests/test_build_local_wheel.py new file mode 100644 index 000000000..d4ba154ad --- /dev/null +++ b/tests/test_build_local_wheel.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from unittest import mock + + +def _load_build_local_wheel_module(): + module_path = ( + Path(__file__).resolve().parents[1] + / "src" + / "build_scripts" + / "build_local_wheel.py" + ) + spec = importlib.util.spec_from_file_location("build_local_wheel", module_path) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_hatch_build_command_uses_python_module_when_available(tmp_path): + build_local_wheel = _load_build_local_wheel_module() + + with ( + mock.patch.object(build_local_wheel.shutil, "which", return_value=None), + mock.patch.object( + build_local_wheel.importlib.util, + "find_spec", + return_value=object(), + ), + ): + assert build_local_wheel._hatch_build_command(tmp_path) == [ + build_local_wheel.sys.executable, + "-m", + "hatch", + "build", + "-t", + "wheel", + ] 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..71744d64b 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -12,16 +12,15 @@ 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(): 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})" ) @@ -29,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): @@ -63,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") @@ -98,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"}), ], ) @@ -339,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() @@ -359,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 @@ -366,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(): @@ -393,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 @@ -405,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): @@ -587,3 +644,124 @@ 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 user inputs are processed in the correct order and that the + event queueing/processing order is consistent with user expectations, even if the + server is still processing previous events. + """ + + @reactpy.component + def ControlledInput(): + value, set_value = use_state("") + + def on_change(event): + set_value(event["target"]["value"]) + + return reactpy.html.div( + reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + }, + ), + reactpy.html.pre({"id": "server-value"}, value), + ) + + 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) + + # Ensure all characters stayed within the client, even if server updates were in-flight + assert (await inp.evaluate("node => node.value")) == target_text + + # Ensure the server and client are in sync + server_value = await display.page.locator("#server-value").text_content() + assert server_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" + + +async def test_controlled_input_default_debounce_reconciles_server_value( + display: DisplayFixture, +): + """Verifies if the client keeps the latest user-provided input value even + if it received a conflicting server update within the debounce period, then + ultimately reconciles once debounce expires.""" + + @reactpy.component + def ControlledInput(): + value, set_value = use_state("") + + def on_change(event: Event): + set_value(event.target.value.upper()) + + return reactpy.html.div( + reactpy.html.input( + { + "value": value, + "onChange": on_change, + "id": "controlled-input", + } + ), + reactpy.html.pre({"id": "server-value"}, value), + ) + + 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( + """ + () => { + const input = document.getElementById('controlled-input'); + const serverValue = document.getElementById('server-value'); + return input?.value === 'a' && serverValue?.textContent === 'A'; + } + """ + ) + assert (await inp.evaluate("node => node.value")) == "a" + assert await display.page.locator("#server-value").text_content() == "A" + + 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_hooks.py b/tests/test_core/test_hooks.py index a4e59f3fd..6856ae187 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,53 @@ async def effect(): event_that_never_occurs.set() +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() + + @reactpy.component + @component_hook.capture + def ComponentWithShieldedEffect(): + @reactpy.hooks.use_async_effect(dependencies=None, shield=True) + async def effect(): + effect_ran.set() + try: + await stop_waiting.wait() + except asyncio.CancelledError: + effect_was_cancelled.set() + raise + 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() + assert not effect_was_cancelled.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) + assert not effect_was_cancelled.is_set() + + 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() @@ -634,7 +687,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 +1472,3 @@ async def effect(): # Verify the previous effect was cancelled await asyncio.wait_for(effect_was_cancelled.wait(), 1) - diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index a3f917699..48e06a153 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,39 @@ 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_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) 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( 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", 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_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_pyscript/test_utils.py b/tests/test_pyscript/test_utils.py index 14a54ca96..7b1ad23fb 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'