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/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..0b4e29e37 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,46 @@ 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); // 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); - } - // allow the client to respond (and possibly change the value) - givenOnChange(event); + 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 >= lastInputDebounce.current + ) { + setValue(props.value); + } + }, [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..e09dee6b6 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, ) @@ -263,7 +270,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 @@ -283,7 +294,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..0c8eeeb1a 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 @@ -47,15 +48,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 +117,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" } }, @@ -143,10 +152,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 +239,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/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..1d9b237d9 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. @@ -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_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_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..c1609ca54 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,96 @@ 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 + + +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_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" 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_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); 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" 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}