Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
6 changes: 5 additions & 1 deletion 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 Expand Up @@ -214,6 +219,5 @@ function useImportSource(model: ReactPyVdom): MutableRefObject<any> {
const SPECIAL_ELEMENTS = {
input: UserInputElement,
script: ScriptElement,
select: UserInputElement,
textarea: UserInputElement,
};
6 changes: 5 additions & 1 deletion src/js/packages/@reactpy/client/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
62 changes: 55 additions & 7 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,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;
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
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
27 changes: 17 additions & 10 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 All @@ -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
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 All @@ -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()
Expand All @@ -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)


Expand All @@ -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 = {}
Expand Down Expand Up @@ -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):
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
Loading
Loading