Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/js/packages/@reactpy/client/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>) => {
// 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
Expand Down
37 changes: 31 additions & 6 deletions src/js/packages/@reactpy/client/src/reactpy-client.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -108,6 +108,7 @@ export type SimpleReactPyClientProps = {
connectionTimeout?: number;
debugMessages?: boolean;
socketLoopThrottle?: number;
pingInterval?: number;
};

/**
Expand Down Expand Up @@ -156,12 +157,13 @@ enum messageTypes {
clientState = "client-state",
stateUpdate = "state-update",
layoutUpdate = "layout-update",
pingIntervalSet = "ping-interval-set",
};

export class SimpleReactPyClient
extends BaseReactPyClient
implements ReactPyClient {
private readonly urls: ServerUrls;
private urls: ServerUrls;
private socket!: { current?: WebSocket };
private idleDisconnectTimeMillis: number;
private lastActivityTime: number;
Expand All @@ -180,6 +182,8 @@ export class SimpleReactPyClient
private didReconnectingCallback: boolean;
private willReconnect: boolean;
private socketLoopThrottle: number;
private pingPongIntervalId?: number | null;
private pingInterval: number;

constructor(props: SimpleReactPyClientProps) {
super();
Expand All @@ -193,6 +197,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;
Expand All @@ -215,8 +220,9 @@ 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.updatePingInterval()
this.reconnect()

const handleUserAction = (ev: any) => {
Expand Down Expand Up @@ -350,11 +356,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);
Expand All @@ -375,6 +390,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;
Expand Down Expand Up @@ -412,6 +435,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;
Expand Down
3 changes: 2 additions & 1 deletion src/js/packages/event-to-object/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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 }),
Expand Down
7 changes: 5 additions & 2 deletions src/py/reactpy/reactpy/backend/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 12 additions & 9 deletions src/py/reactpy/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -100,8 +103,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 = {}
Expand Down Expand Up @@ -190,12 +196,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):
Expand Down
14 changes: 7 additions & 7 deletions src/py/reactpy/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
FIRST_COMPLETED,
CancelledError,
PriorityQueue,
Queue,
Task,
create_task,
get_running_loop,
Expand All @@ -19,9 +18,8 @@
from logging import getLogger
from typing import (
Any,
Awaitable,
AsyncIterable,
Callable,
Coroutine,
Generic,
NamedTuple,
NewType,
Expand Down Expand Up @@ -57,7 +55,6 @@
Key,
LayoutEventMessage,
LayoutUpdateMessage,
StateUpdateMessage,
VdomChild,
VdomDict,
VdomJson,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
65 changes: 53 additions & 12 deletions src/py/reactpy/reactpy/core/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -22,12 +23,26 @@
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[
[
Expand Down Expand Up @@ -128,24 +143,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"))
Expand All @@ -172,8 +198,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"))
Expand All @@ -197,7 +237,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"
Expand Down
2 changes: 1 addition & 1 deletion src/py/reactpy/reactpy/core/state_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/py/reactpy/reactpy/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ 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 Context(Protocol[_Type]):
"""Returns a :class:`ContextProvider` component"""

Expand Down
Loading