diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index fd23d3a8a..0f1c1722d 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -29,13 +29,12 @@ export function Layout(props: { client: ReactPyClient }): JSX.Element { useEffect( () => - props.client.onMessage("layout-update", ({ path, model, state_vars }) => { + props.client.onLayoutUpdate((path: string, model: any) => { if (path === "") { Object.assign(currentModel, model); } else { setJsonPointer(currentModel, path, model); } - props.client.updateStateVars(state_vars); forceUpdate(); }), [currentModel, props.client], diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index c5018e9a5..c69db96bc 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -16,6 +16,8 @@ export interface ReactPyClient { */ onMessage(type: string, handler: (message: any) => void): () => void; + onLayoutUpdate(handler: (path: string, model: any) => void): void; + /** * Send a message to the server. * @@ -43,6 +45,7 @@ export abstract class BaseReactPyClient implements ReactPyClient { private resolveReady: (value: undefined) => void; protected stateVars: object; protected debugMessages: boolean; + protected layoutUpdateHandlers: Array<(path: string, model: any) => void> = []; constructor() { this.resolveReady = () => { }; @@ -59,6 +62,10 @@ export abstract class BaseReactPyClient implements ReactPyClient { }; } + onLayoutUpdate(handler: (path: string, model: any) => void): void { + this.layoutUpdateHandlers.push(handler); + } + abstract sendMessage(message: any): void; abstract loadModule(moduleName: string): Promise; @@ -146,7 +153,8 @@ enum messageTypes { isReady = "is-ready", reconnectingCheck = "reconnecting-check", clientState = "client-state", - stateUpdate = "state-update" + stateUpdate = "state-update", + layoutUpdate = "layout-update", }; export class SimpleReactPyClient @@ -198,6 +206,10 @@ export class SimpleReactPyClient this.onMessage(messageTypes.isReady, (msg) => { this.isReady = true; this.salt = msg.salt; }); this.onMessage(messageTypes.clientState, () => { this.sendClientState() }); this.onMessage(messageTypes.stateUpdate, (msg) => { this.updateClientState(msg.state_vars) }); + this.onMessage(messageTypes.layoutUpdate, (msg) => { + this.updateClientState(msg.state_vars); + this.invokeLayoutUpdateHandlers(msg.path, msg.model); + }) this.reconnect() @@ -211,7 +223,13 @@ export class SimpleReactPyClient window.addEventListener('scroll', reconnectOnUserAction); } - showReconnectingGrayout() { + protected invokeLayoutUpdateHandlers(path: string, model: any) { + this.layoutUpdateHandlers.forEach(func => { + func(path, model); + }); + } + + protected showReconnectingGrayout() { const overlay = document.createElement('div'); overlay.id = 'reactpy-reconnect-overlay'; @@ -229,7 +247,7 @@ export class SimpleReactPyClient display: flex; justify-content: center; align-items: center; - z-index: 1000; + z-index: 100000; `; pipeContainer.style.cssText = ` @@ -328,6 +346,10 @@ export class SimpleReactPyClient const maxInterval = this.reconnectOptions?.maxInterval || 20000; const maxRetries = this.reconnectOptions?.maxRetries || 20; + if (this.layoutUpdateHandlers.length == 0) { + setTimeout(() => { this.reconnect(onOpen, interval, connectionAttemptsRemaining, lastAttempt); }, 10); + return + } if (connectionAttemptsRemaining <= 0) { logger.warn("Giving up on reconnecting (hit retry limit)"); @@ -344,7 +366,7 @@ export class SimpleReactPyClient this.shouldReconnect = true; window.setTimeout(() => { - if (!this.didReconnectingCallback && this.reconnectingCallback) { + if (!this.didReconnectingCallback && this.reconnectingCallback && maxRetries != connectionAttemptsRemaining) { this.didReconnectingCallback = true; this.reconnectingCallback(); } diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index bad90b072..e648747fa 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -193,16 +193,6 @@ async def model_stream( ), constructor, ) - # await serve_layout( - # Layout( - # ConnectionContext( - # constructor(), - # value=, - # ) - # ), - # send, - # recv, - # ) api_blueprint.add_websocket_route( model_stream, diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 38be33786..6979de7ff 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -1,5 +1,6 @@ import asyncio import base64 +from dataclasses import asdict, is_dataclass import datetime import hashlib import time @@ -58,6 +59,8 @@ def __init__( datetime.datetime, datetime.date, datetime.time, + datetime.timezone, + datetime.timedelta, ] ) @@ -65,7 +68,7 @@ def _map_objects_to_ids(self, serializable_types: Iterable[type]) -> dict: self._object_to_type_id = {} self._type_id_to_object = {} for idx, typ in enumerate( - (None, bool, str, int, float, list, tuple, UUID, *serializable_types) + (None, bool, str, int, float, list, tuple, UUID, datetime.timezone, datetime.timedelta, *serializable_types) ): idx_as_bytes = str(idx).encode("utf-8") self._object_to_type_id[typ] = idx_as_bytes @@ -132,8 +135,13 @@ def __init__( self._type_id_to_object = type_id_to_object self._max_object_length = max_object_length self._max_num_state_objects = max_num_state_objects - self._default_serializer = default_serializer - self._deserializer_map = deserializer_map or {} + self._provided_default_serializer = default_serializer + deserialization_map = { + datetime.timezone: lambda x: datetime.timezone( + datetime.timedelta(**x["offset"]), x["name"] + ), + } + self._deserializer_map = deserialization_map | (deserializer_map or {}) def _get_otp_code(self, target_time: float) -> str: at = self._totp.at @@ -253,6 +261,17 @@ def _sign_serialization( def _serialize_object(self, obj: Any) -> bytes: return orjson.dumps(obj, default=self._default_serializer) + def _default_serializer(self, obj: Any) -> bytes: + if isinstance(obj, datetime.timezone): + return {"name": obj.tzname(None), "offset": obj.utcoffset(None)} + if isinstance(obj, datetime.timedelta): + return {"days": obj.days, "seconds": obj.seconds, "microseconds": obj.microseconds} + if is_dataclass(obj): + return asdict(obj) + if self._provided_default_serializer: + return self._provided_default_serializer(obj) + raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") + def _do_deserialize( self, typ: type, result: Any, custom_deserializer: Callable | None ) -> Any: diff --git a/src/py/reactpy/reactpy/core/vdom.py b/src/py/reactpy/reactpy/core/vdom.py index e494b5269..2bb3120dd 100644 --- a/src/py/reactpy/reactpy/core/vdom.py +++ b/src/py/reactpy/reactpy/core/vdom.py @@ -328,7 +328,7 @@ def _validate_child_key_integrity(value: Any) -> None: ) else: for child in value: - if isinstance(child, ComponentType) and child.key is None: + if child.key is None and isinstance(child, ComponentType): warn(f"Key not specified for child in list {child}", UserWarning) elif isinstance(child, Mapping) and "key" not in child: # remove 'children' to reduce log spam