diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 0f1c1722d..6dd3b5f94 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -99,6 +99,11 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { // allow the client to respond (and possibly change the value) givenOnChange(event); }; + } else if (!givenOnChange) { + props.onChange = (event: ChangeEvent) => { + // set the value so rerender doesn't stomp on state + setValue(event.target.value); + } } // Use createElement here to avoid warning about variable numbers of children not @@ -214,6 +219,5 @@ function useImportSource(model: ReactPyVdom): MutableRefObject { const SPECIAL_ELEMENTS = { input: UserInputElement, script: ScriptElement, - select: UserInputElement, textarea: UserInputElement, }; diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts index 5fbfc24bf..2518e27df 100644 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ b/src/js/packages/@reactpy/client/src/messages.ts @@ -17,6 +17,10 @@ export type ReconnectingCheckMessage = { value: string; } -export type IncomingMessage = LayoutUpdateMessage | ReconnectingCheckMessage; +export type AckMessage = { + type: "ack" +} + +export type IncomingMessage = LayoutUpdateMessage | ReconnectingCheckMessage | AckMessage; export type OutgoingMessage = LayoutEventMessage | ReconnectingCheckMessage; export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index b840479a0..f17c2c1f3 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -1,5 +1,5 @@ -import { ReactPyModule } from "./reactpy-vdom"; import logger from "./logger"; +import { ReactPyModule } from "./reactpy-vdom"; /** * A client for communicating with a ReactPy server. @@ -108,6 +108,7 @@ export type SimpleReactPyClientProps = { connectionTimeout?: number; debugMessages?: boolean; socketLoopThrottle?: number; + pingInterval?: number; }; /** @@ -156,12 +157,14 @@ enum messageTypes { clientState = "client-state", stateUpdate = "state-update", layoutUpdate = "layout-update", + pingIntervalSet = "ping-interval-set", + ackMessage = "ack" }; export class SimpleReactPyClient extends BaseReactPyClient implements ReactPyClient { - private readonly urls: ServerUrls; + private urls: ServerUrls; private socket!: { current?: WebSocket }; private idleDisconnectTimeMillis: number; private lastActivityTime: number; @@ -180,6 +183,9 @@ export class SimpleReactPyClient private didReconnectingCallback: boolean; private willReconnect: boolean; private socketLoopThrottle: number; + private pingPongIntervalId?: number | null; + private pingInterval: number; + private messageResponseTimeoutId?: number | null; constructor(props: SimpleReactPyClientProps) { super(); @@ -193,6 +199,7 @@ export class SimpleReactPyClient ); this.idleDisconnectTimeMillis = (props.idleDisconnectTimeSeconds || 240) * 1000; this.connectionTimeout = props.connectionTimeout || 5000; + this.pingInterval = props.pingInterval || 0; this.lastActivityTime = Date.now() this.reconnectOptions = props.reconnectOptions this.debugMessages = props.debugMessages || false; @@ -215,8 +222,10 @@ export class SimpleReactPyClient this.updateClientState(msg.state_vars); this.invokeLayoutUpdateHandlers(msg.path, msg.model); this.willReconnect = true; // don't indicate a reconnect until at least one successful layout update - }) - + }); + this.onMessage(messageTypes.pingIntervalSet, (msg) => { this.pingInterval = msg.ping_interval; this.updatePingInterval(); }); + this.onMessage(messageTypes.ackMessage, () => {}) + this.updatePingInterval() this.reconnect() const handleUserAction = (ev: any) => { @@ -334,9 +343,21 @@ export class SimpleReactPyClient } this.lastActivityTime = Date.now(); this.socket.current.send(JSON.stringify(message)); + + // Start response timeout for reconnecting grayout + if (this.messageResponseTimeoutId) { + window.clearTimeout(this.messageResponseTimeoutId); + } + this.messageResponseTimeoutId = window.setTimeout(() => { + this.showReconnectingGrayout(); + }, 800); } } + protected handleIncoming(message: any): void { + super.handleIncoming(message); + } + idleTimeoutCheck(): void { if (!this.socket) return; @@ -350,11 +371,20 @@ export class SimpleReactPyClient } } + updatePingInterval(): void { + if (this.pingPongIntervalId) { + window.clearInterval(this.pingPongIntervalId); + } + if (this.pingInterval) { + this.pingPongIntervalId = window.setInterval(() => { this.socket.current?.readyState === WebSocket.OPEN && this.socket.current?.send("ping") }, this.pingInterval); + } + } + reconnect(onOpen?: () => void, interval: number = 750, connectionAttemptsRemaining: number = 20, lastAttempt: number = 0): void { const intervalJitter = this.reconnectOptions?.intervalJitter || 0.5; const backoffRate = this.reconnectOptions?.backoffRate || 1.2; - const maxInterval = this.reconnectOptions?.maxInterval || 20000; - const maxRetries = this.reconnectOptions?.maxRetries || 20; + const maxInterval = this.reconnectOptions?.maxInterval || 500; + const maxRetries = this.reconnectOptions?.maxRetries || 40; if (this.layoutUpdateHandlers.length == 0) { setTimeout(() => { this.reconnect(onOpen, interval, connectionAttemptsRemaining, lastAttempt); }, 10); @@ -375,6 +405,14 @@ export class SimpleReactPyClient lastAttempt = lastAttempt || Date.now(); this.shouldReconnect = true; + this.urls = getServerUrls( + { + url: document.location.origin, + route: document.location.pathname, + query: document.location.search, + }, + ); + window.setTimeout(() => { if (!this.didReconnectingCallback && this.reconnectingCallback && maxRetries != connectionAttemptsRemaining) { this.didReconnectingCallback = true; @@ -412,6 +450,8 @@ export class SimpleReactPyClient clearInterval(this.socketLoopIntervalId); if (this.idleCheckIntervalId) clearInterval(this.idleCheckIntervalId); + if (this.pingPongIntervalId) + clearInterval(this.pingPongIntervalId); if (!this.sleeping) { const thisInterval = nextInterval(addJitter(interval, intervalJitter), backoffRate, maxInterval); const newRetriesRemaining = connectionAttemptsRemaining - 1; @@ -421,7 +461,15 @@ export class SimpleReactPyClient this.reconnect(onOpen, thisInterval, newRetriesRemaining, lastAttempt); } }, - onMessage: async ({ data }) => { this.lastActivityTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, + onMessage: async ({ data }) => { + this.lastActivityTime = Date.now(); + if (this.messageResponseTimeoutId) { + window.clearTimeout(this.messageResponseTimeoutId); + this.messageResponseTimeoutId = null; + this.hideReconnectingGrayout(); + } + this.handleIncoming(JSON.parse(data)); + }, ...this.reconnectOptions, }); this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, this.socketLoopThrottle); diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 9a40a2128..e6e9f9ab5 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -276,6 +276,7 @@ function convertElement(element: EventTarget | HTMLElement | null): any { const convertGenericElement = (element: HTMLElement) => ({ tagName: element.tagName, boundingClientRect: { ...element.getBoundingClientRect() }, + id: element.id, }); const convertMediaElement = (element: HTMLMediaElement) => ({ @@ -303,7 +304,7 @@ const elementConverters: { [key: string]: (element: any) => any } = { FORM: (element: HTMLFormElement) => ({ elements: Array.from(element.elements).map(convertElement), }), - INPUT: (element: HTMLInputElement) => ({ value: element.value }), + INPUT: (element: HTMLInputElement) => ({ value: element.value, checked: element.checked }), METER: (element: HTMLMeterElement) => ({ value: element.value }), OPTION: (element: HTMLOptionElement) => ({ value: element.value }), OUTPUT: (element: HTMLOutputElement) => ({ value: element.value }), diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index e648747fa..0018c97c3 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -7,8 +7,8 @@ from typing import Any from urllib import parse as urllib_parse from uuid import uuid4 -import orjson +import orjson from sanic import Blueprint, Sanic, request, response from sanic.config import Config from sanic.server.websockets.connection import WebSocketConnection @@ -213,7 +213,10 @@ async def sock_send(value: Any) -> None: await socket.send(orjson.dumps(value).decode("utf-8")) async def sock_recv() -> Any: - data = await socket.recv() + while True: + data = await socket.recv() + if data != "ping": + break if data is None: raise Stop() return orjson.loads(data) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 95fefaf94..3a27fc8aa 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -3,6 +3,7 @@ import asyncio from functools import lru_cache import hashlib +import linecache import sys from collections.abc import Coroutine, Sequence from hashlib import md5 @@ -22,7 +23,7 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._life_cycle_hook import get_current_hook +from reactpy.core._life_cycle_hook import LifeCycleHook, get_current_hook from reactpy.core.state_recovery import StateRecoveryFailureError from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref @@ -54,7 +55,9 @@ class ReconnectingOnly(list): @overload -def use_state(initial_value: Callable[[], _Type], *, server_only: bool = False) -> State[_Type]: ... +def use_state( + initial_value: Callable[[], _Type], *, server_only: bool = False +) -> State[_Type]: ... @overload @@ -75,8 +78,10 @@ def use_state( Returns: A tuple containing the current state and a function to update it. """ + hook: LifeCycleHook | None if server_only: key = None + hook = None else: hook = get_current_hook() caller_info = get_caller_info() @@ -89,6 +94,8 @@ def use_state( f"Missing expected key {key} on client" ) from err current_state = _use_const(lambda: _CurrentState(key, initial_value)) + if hook: + hook.add_state_update(current_state) return State(current_state.value, current_state.dispatch) @@ -100,8 +107,11 @@ def get_caller_info(): patch_path = render_frame.f_locals.get("patch_path_for_state") if patch_path is not None: break - # Extract the relevant information: file path and line number and hash it - return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" + # Extract the relevant information: file path, line number, and line and hash it + filename = caller_frame.f_code.co_filename + lineno = caller_frame.f_lineno + line = linecache.getline(filename, lineno) + return f"{filename} {lineno} {line}, {patch_path}" __DEBUG_CALLER_INFO_TO_STATE_KEY = {} @@ -190,12 +200,9 @@ def use_effect( hook = get_current_hook() if hook.reconnecting.current: if not isinstance(dependencies, ReconnectingOnly): - return - dependencies = None - else: - if isinstance(dependencies, ReconnectingOnly): - return - dependencies = _try_to_infer_closure_values(function, dependencies) + return memoize(lambda: None) + elif isinstance(dependencies, ReconnectingOnly): + return def add_effect(function: _EffectApplyFunc) -> None: if not asyncio.iscoroutinefunction(function): diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f2274c6a8..2c4ada19c 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -7,7 +7,6 @@ FIRST_COMPLETED, CancelledError, PriorityQueue, - Queue, Task, create_task, get_running_loop, @@ -19,9 +18,8 @@ from logging import getLogger from typing import ( Any, - Awaitable, + AsyncIterable, Callable, - Coroutine, Generic, NamedTuple, NewType, @@ -57,7 +55,6 @@ Key, LayoutEventMessage, LayoutUpdateMessage, - StateUpdateMessage, VdomChild, VdomDict, VdomJson, @@ -155,7 +152,10 @@ async def finish(self) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id - clear_hook_state(self._hook_state_token) + try: + clear_hook_state(self._hook_state_token) + except LookupError: + pass def start_rendering(self) -> None: self._schedule_render_task(self._root_life_cycle_state_id) @@ -188,7 +188,7 @@ async def render(self) -> LayoutUpdateMessage: else: # nocov return await self._serial_render() - async def render_until_queue_empty(self) -> None: + async def render_until_queue_empty(self) -> AsyncIterable[LayoutUpdateMessage]: model_state_id = await self._rendering_queue.get() while True: try: @@ -199,7 +199,7 @@ async def render_until_queue_empty(self) -> None: f"{model_state_id!r} - component already unmounted" ) else: - await self._create_layout_update(model_state, get_hook_state()) + yield await self._create_layout_update(model_state, get_hook_state()) # this might seem counterintuitive. What's happening is that events can get kicked off # and currently there's no (obvious) visibility on if we're waiting for them to finish # so this will wait up to 0.15 * 5 = 750 ms to see if any renders come in before diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 85799c762..4da4fcc6a 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -4,6 +4,7 @@ import string from collections.abc import Awaitable from logging import getLogger +from os import environ from typing import Callable from warnings import warn @@ -17,17 +18,32 @@ from reactpy.core.layout import Layout from reactpy.core.state_recovery import StateRecoveryFailureError, StateRecoveryManager from reactpy.core.types import ( + AckMessage, ClientStateMessage, IsReadyMessage, LayoutEventMessage, LayoutType, LayoutUpdateMessage, + PingIntervalSetMessage, ReconnectingCheckMessage, RootComponentConstructor, ) logger = getLogger(__name__) +MAX_HOT_RELOADING = environ.get("REACTPY_MAX_HOT_RELOADING", "0") in ( + "1", + "true", + "True", + "yes", +) +if MAX_HOT_RELOADING: + logger.warning("Doing maximum hot reloading") + from reactpy.hot_reloading import ( + monkeypatch_jurigged_to_kill_connections_if_function_update, + ) + + monkeypatch_jurigged_to_kill_connections_if_function_update() SendCoroutine = Callable[ [ @@ -110,8 +126,9 @@ async def _single_incoming_loop( while True: # We need to fire and forget here so that we avoid waiting on the completion # of this event handler before receiving and running the next one. - task_group.start_soon(layout.deliver, await recv()) + task_group.start_soon(layout.deliver, await recv()) + task_group.start_soon(send, AckMessage(type="ack")) class WebsocketServer: def __init__( @@ -128,24 +145,35 @@ def __init__( async def handle_connection( self, connection: Connection, constructor: RootComponentConstructor ): + if MAX_HOT_RELOADING: + from reactpy.hot_reloading import active_connections + + active_connections.append(connection) layout = Layout( ConnectionContext( constructor(), value=connection, ), ) - async with layout: - await self._handshake(layout) - # salt may be set to client's old salt during handshake - if self._state_recovery_manager: - layout.set_recovery_serializer( - self._state_recovery_manager.create_serializer(self._salt) + try: + async with layout: + await self._handshake(layout) + # salt may be set to client's old salt during handshake + if self._state_recovery_manager: + layout.set_recovery_serializer( + self._state_recovery_manager.create_serializer(self._salt) + ) + await serve_layout( + layout, + self._send, + self._recv, ) - await serve_layout( - layout, - self._send, - self._recv, - ) + finally: + if MAX_HOT_RELOADING: + try: + active_connections.remove(connection) + except ValueError: + pass async def _handshake(self, layout: Layout) -> None: await self._send(ReconnectingCheckMessage(type="reconnecting-check")) @@ -172,8 +200,22 @@ async def _handshake(self, layout: Layout) -> None: await self._indicate_ready(), async def _indicate_ready(self) -> None: + if MAX_HOT_RELOADING: + await self._send( + PingIntervalSetMessage(type="ping-interval-set", ping_interval=250) + ) await self._send(IsReadyMessage(type="is-ready", salt=self._salt)) + if MAX_HOT_RELOADING: + + async def _handle_rebuild_msg(self, msg: LayoutUpdateMessage) -> None: + await self._send(msg) + + else: + + async def _handle_rebuild_msg(self, msg: LayoutUpdateMessage) -> None: + pass # do nothing + async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: salt = self._salt await self._send(ClientStateMessage(type="client-state")) @@ -197,7 +239,8 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: salt = client_state_msg["salt"] layout.start_rendering_for_reconnect() - await layout.render_until_queue_empty() + async for msg in layout.render_until_queue_empty(): + await self._handle_rebuild_msg(msg) except StateRecoveryFailureError: logger.warning( "State recovery failed (likely client from different version). Starting fresh" diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 59c8c89da..eeea4d8ab 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -158,7 +158,7 @@ async def serialize_state_vars( ) return {} result = {} - for chunk in chunked(state_vars.items(), 50): + for chunk in chunked(list(state_vars.items()), 50): for key, value in chunk: result[key] = self._serialize(key, value) await asyncio.sleep(0) # relinquish CPU diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index a4be74f61..c037eadc6 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -263,6 +263,16 @@ class LayoutEventMessage(TypedDict): """A list of event data passed to the event handler.""" +class PingIntervalSetMessage(TypedDict): + type: Literal["ping-interval-set"] + + ping_interval: int + + +class AckMessage(TypedDict): + type: Literal["ack-message"] + + class Context(Protocol[_Type]): """Returns a :class:`ContextProvider` component""" diff --git a/src/py/reactpy/reactpy/hot_reloading.py b/src/py/reactpy/reactpy/hot_reloading.py new file mode 100644 index 000000000..38c33cf01 --- /dev/null +++ b/src/py/reactpy/reactpy/hot_reloading.py @@ -0,0 +1,38 @@ +import asyncio +import logging + +logger = logging.getLogger(__name__) + +active_connections = [] + + +def monkeypatch_jurigged_to_kill_connections_if_function_update(): + import jurigged.codetools as jurigged_codetools # type: ignore + import jurigged.utils as jurigged_utils # type: ignore + + OrigFunctionDefinition = jurigged_codetools.FunctionDefinition + + class NewFunctionDefinition(OrigFunctionDefinition): + def reevaluate(self, new_node, glb): + if active_connections: + logger.info("Killing active connections") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + tasks = [ + connection.carrier.websocket.close() + for connection in active_connections + ] + loop.run_until_complete(asyncio.gather(*tasks)) + loop.close() + active_connections.clear() + return super().reevaluate(new_node, glb) + + def stash(self, lineno=1, col_offset=0): + if not isinstance(self.parent, OrigFunctionDefinition): + co = self.get_object() + if co and (delta := lineno - self.node.extent.lineno): + self.recode(jurigged_utils.shift_lineno(co, delta), use_cache=False) + + return super(OrigFunctionDefinition, self).stash(lineno, col_offset) + + jurigged_codetools.FunctionDefinition = NewFunctionDefinition