From 3a84b2a9c59afea90fdb4e5be4dd6389ad8c4d4d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 19:36:19 +0000 Subject: [PATCH 001/166] wip connection robustness --- src/js/package-lock.json | 4 +- .../packages/@reactpy/client/src/messages.ts | 9 ++- .../@reactpy/client/src/reactpy-client.ts | 64 ++++++++++++++++--- src/py/reactpy/reactpy/core/serve.py | 24 ++++++- src/py/reactpy/reactpy/core/types.py | 8 +++ 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 2edfdd260..2904bba0e 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -28,7 +28,7 @@ "@types/react": "^17.0", "@types/react-dom": "^17.0", "typescript": "^4.9.5", - "vite": "^3.1.8" + "vite": "^3.2.7" } }, "app/node_modules/@reactpy/client": { @@ -3955,7 +3955,7 @@ "@types/react-dom": "^17.0", "preact": "^10.7.0", "typescript": "^4.9.5", - "vite": "^3.1.8" + "vite": "^3.2.7" }, "dependencies": { "@reactpy/client": { diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts index 34001dcb0..5fbfc24bf 100644 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ b/src/js/packages/@reactpy/client/src/messages.ts @@ -12,6 +12,11 @@ export type LayoutEventMessage = { data: any; }; -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; +export type ReconnectingCheckMessage = { + type: "reconnecting-check"; + value: string; +} + +export type IncomingMessage = LayoutUpdateMessage | ReconnectingCheckMessage; +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 6f37b55a1..9c530cb02 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -37,7 +37,7 @@ export abstract class BaseReactPyClient implements ReactPyClient { private resolveReady: (value: undefined) => void; constructor() { - this.resolveReady = () => {}; + this.resolveReady = () => { }; this.ready = new Promise((resolve) => (this.resolveReady = resolve)); } @@ -79,6 +79,8 @@ export abstract class BaseReactPyClient implements ReactPyClient { export type SimpleReactPyClientProps = { serverLocation?: LocationProps; reconnectOptions?: ReconnectProps; + forceRerender?: boolean; + idleDisconnectTimeSeconds?: number; }; /** @@ -121,10 +123,16 @@ type ReconnectProps = { export class SimpleReactPyClient extends BaseReactPyClient - implements ReactPyClient -{ + implements ReactPyClient { private readonly urls: ServerUrls; - private readonly socket: { current?: WebSocket }; + private socket!: { current?: WebSocket }; + private idleDisconnectTimeMillis: number; + private lastMessageTime: number; + private reconnectOptions: ReconnectProps | undefined; + // @ts-ignore + private forceRerender: boolean; + private messageQueue: any[] = []; + private intervalId: number | null; constructor(props: SimpleReactPyClientProps) { super(); @@ -136,22 +144,62 @@ export class SimpleReactPyClient query: document.location.search, }, ); + this.idleDisconnectTimeMillis = (props.idleDisconnectTimeSeconds || 240) * 1000; + this.forceRerender = props.forceRerender !== undefined ? props.forceRerender : false; + this.lastMessageTime = Date.now() + this.reconnectOptions = props.reconnectOptions + this.reconnect() + this.intervalId = window.setInterval(this.socketLoop, 25); + } + + socketLoop(): void { + if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN && this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); // Remove the first message from the queue + this.socket.current.send(JSON.stringify(message)); + } + this.idleTimeoutCheck(); + } + + idleTimeoutCheck(): void { + if (Date.now() - this.lastMessageTime > this.idleDisconnectTimeMillis) { + if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { + this.socket.current.close(); + } + } + } + + reconnect(onOpen?: () => void): void { this.socket = createReconnectingWebSocket({ readyPromise: this.ready, url: this.urls.stream, + onOpen: onOpen, onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, + ...this.reconnectOptions, }); } + ensureConnected(): void { + if (this.socket.current?.readyState == WebSocket.CLOSED) { + this.reconnect(); + } + } + sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); + this.messageQueue.push(message); + this.ensureConnected(); } loadModule(moduleName: string): Promise { return import(`${this.urls.modules}/${moduleName}`); } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } } type ServerUrls = { @@ -247,9 +295,9 @@ function nextInterval( maxInterval: number, ): number { return Math.min( - currentInterval * + (currentInterval * // increase interval by backoff rate - backoffRate, + backoffRate), // don't exceed max interval maxInterval, ); diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 3a540af59..314e40e1a 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -9,15 +9,15 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage +from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | ReconnectingCheckMessage]] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -81,3 +81,21 @@ async def _single_incoming_loop( # 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()) + + +async def handshake( + send: SendCoroutine, + recv: RecvCoroutine, +) -> None: + await send({"type": "reconnecting-check"}) + result = await recv() + if result['type'] == "reconnecting-check": + if result["value"] == "yes": + await do_reconnection(send, recv) + + +async def do_reconnection( + send: SendCoroutine, + recv: RecvCoroutine, +) -> None: + pass diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index b451be30a..e32e4f952 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -215,6 +215,14 @@ class LayoutUpdateMessage(TypedDict): """The model to assign at the given JSON Pointer path""" +class ReconnectingCheckMessage(TypedDict): + """A message describing an update to a layout""" + + type: Literal["reconnecting-check"] + """The type of message""" + value: Literal["yes", "no"] + + class LayoutEventMessage(TypedDict): """Message describing an event originating from an element in the layout""" From 03d4cffd41a48b51f9b86cbd76901f9f73113873 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 19:43:25 +0000 Subject: [PATCH 002/166] Try fixing double execution bug for scripts --- .../@reactpy/client/src/components.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 728c4cec7..499f00150 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -125,23 +125,15 @@ function ScriptElement({ model }: { model: ReactPyVdom }) { (value): value is string => typeof value == "string", )[0]; - let scriptElement: HTMLScriptElement; - if (model.attributes) { - scriptElement = document.createElement("script"); - for (const [k, v] of Object.entries(model.attributes)) { - scriptElement.setAttribute(k, v); - } - if (scriptContent) { - scriptElement.appendChild(document.createTextNode(scriptContent)); - } - ref.current.appendChild(scriptElement); - } else if (scriptContent) { - const scriptResult = eval(scriptContent); - if (typeof scriptResult == "function") { - return scriptResult(); - } + const scriptElement: HTMLScriptElement = document.createElement("script"); + for (const [k, v] of Object.entries(model.attributes || {})) { + scriptElement.setAttribute(k, v); + } + if (scriptContent) { + scriptElement.appendChild(document.createTextNode(scriptContent)); } - }, [model.key, ref.current]); + ref.current.appendChild(scriptElement); + }, [model.key]); return
; } From 20953d02bf62f78a57c0d3fc866998dd48b8d087 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:26:00 +0000 Subject: [PATCH 003/166] socket check --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 9c530cb02..fa97cb345 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -154,6 +154,8 @@ export class SimpleReactPyClient } socketLoop(): void { + if (!this.socket) + return; if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN && this.messageQueue.length > 0) { const message = this.messageQueue.shift(); // Remove the first message from the queue this.socket.current.send(JSON.stringify(message)); From 8a4f1e0e61a44e0d26135fe7e6f5dda24f4dc703 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:30:14 +0000 Subject: [PATCH 004/166] debug --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index fa97cb345..d6ac3c64b 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -65,6 +65,8 @@ export abstract class BaseReactPyClient implements ReactPyClient { return; } + logger.log("Got message", message); + const messageHandlers: ((m: any) => void)[] | undefined = this.handlers[message.type]; if (!messageHandlers) { From ab14c85086b94fdcfcdd08ed7d7a46b3084949d9 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:34:24 +0000 Subject: [PATCH 005/166] slow socketLoop and add debug message --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index d6ac3c64b..eb852a4d1 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -152,7 +152,7 @@ export class SimpleReactPyClient this.reconnectOptions = props.reconnectOptions this.reconnect() - this.intervalId = window.setInterval(this.socketLoop, 25); + this.intervalId = window.setInterval(this.socketLoop, 75); } socketLoop(): void { @@ -160,6 +160,7 @@ export class SimpleReactPyClient return; if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN && this.messageQueue.length > 0) { const message = this.messageQueue.shift(); // Remove the first message from the queue + logger.log("Sending message", message); this.socket.current.send(JSON.stringify(message)); } this.idleTimeoutCheck(); From b940c519253ddcfb3e142472d698654ff47337a7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:42:16 +0000 Subject: [PATCH 006/166] attempt at bug fix --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index eb852a4d1..b91879a0a 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -152,7 +152,7 @@ export class SimpleReactPyClient this.reconnectOptions = props.reconnectOptions this.reconnect() - this.intervalId = window.setInterval(this.socketLoop, 75); + this.intervalId = window.setInterval(() => { this.socketLoop() }, 75); } socketLoop(): void { From 45dae0b77f02db51847f8ab6fc25efd0f64e7779 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:58:09 +0000 Subject: [PATCH 007/166] Remove reconnecting from web socket creation behavior --- .../@reactpy/client/src/reactpy-client.ts | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index b91879a0a..89cadac12 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -152,7 +152,7 @@ export class SimpleReactPyClient this.reconnectOptions = props.reconnectOptions this.reconnect() - this.intervalId = window.setInterval(() => { this.socketLoop() }, 75); + this.intervalId = window.setInterval(() => { this.socketLoop() }, 50); } socketLoop(): void { @@ -169,17 +169,20 @@ export class SimpleReactPyClient idleTimeoutCheck(): void { if (Date.now() - this.lastMessageTime > this.idleDisconnectTimeMillis) { if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { + logger.warn("Closing socket connection due to idle activity"); this.socket.current.close(); + if (this.intervalId) + clearInterval(this.intervalId); } } } reconnect(onOpen?: () => void): void { - this.socket = createReconnectingWebSocket({ + this.socket = createWebSocket({ readyPromise: this.ready, url: this.urls.stream, onOpen: onOpen, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), + onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, ...this.reconnectOptions, }); } @@ -192,6 +195,7 @@ export class SimpleReactPyClient sendMessage(message: any): void { this.messageQueue.push(message); + this.lastMessageTime = Date.now() this.ensureConnected(); } @@ -226,25 +230,25 @@ function getServerUrls(props: LocationProps): ServerUrls { return { base, modules, assets, stream }; } -function createReconnectingWebSocket( +function createWebSocket( props: { url: string; readyPromise: Promise; onOpen?: () => void; onMessage: (message: MessageEvent) => void; onClose?: () => void; - } & ReconnectProps, + }, ) { - const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, - } = props; - - const startInterval = 750; - let retries = 0; - let interval = startInterval; + // const { + // maxInterval = 60000, + // maxRetries = 50, + // backoffRate = 1.1, + // intervalJitter = 0.1, + // } = props; + + // const startInterval = 750; + // let retries = 0; + // let interval = startInterval; const closed = false; let everConnected = false; const socket: { current?: WebSocket } = {}; @@ -257,8 +261,8 @@ function createReconnectingWebSocket( socket.current.onopen = () => { everConnected = true; logger.log("client connected"); - interval = startInterval; - retries = 0; + // interval = startInterval; + // retries = 0; if (props.onOpen) { props.onOpen(); } @@ -275,17 +279,17 @@ function createReconnectingWebSocket( props.onClose(); } - if (retries >= maxRetries) { - return; - } - - const thisInterval = addJitter(interval, intervalJitter); - logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); - retries++; + // if (retries >= maxRetries) { + // return; + // } + + // const thisInterval = addJitter(interval, intervalJitter); + // logger.log( + // `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, + // ); + // setTimeout(connect, thisInterval); + // interval = nextInterval(interval, backoffRate, maxInterval); + // retries++; }; }; @@ -294,23 +298,23 @@ function createReconnectingWebSocket( return socket; } -function nextInterval( - currentInterval: number, - backoffRate: number, - maxInterval: number, -): number { - return Math.min( - (currentInterval * - // increase interval by backoff rate - backoffRate), - // don't exceed max interval - maxInterval, - ); -} - -function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -} +// function nextInterval( +// currentInterval: number, +// backoffRate: number, +// maxInterval: number, +// ): number { +// return Math.min( +// (currentInterval * +// // increase interval by backoff rate +// backoffRate), +// // don't exceed max interval +// maxInterval, +// ); +// } + +// function addJitter(interval: number, jitter: number): number { +// return interval + (Math.random() * jitter * interval * 2 - jitter * interval); +// } function rtrim(text: string, trim: string): string { return text.replace(new RegExp(`${trim}+$`), ""); From c62c3ec504c47b9b102839c8ae7830663baa7559 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:15:57 +0000 Subject: [PATCH 008/166] Make loop restart on reconnection --- .../@reactpy/client/src/reactpy-client.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 89cadac12..ecc118138 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -134,7 +134,7 @@ export class SimpleReactPyClient // @ts-ignore private forceRerender: boolean; private messageQueue: any[] = []; - private intervalId: number | null; + private socketLoopIntervalId?: number | null; constructor(props: SimpleReactPyClientProps) { super(); @@ -152,7 +152,6 @@ export class SimpleReactPyClient this.reconnectOptions = props.reconnectOptions this.reconnect() - this.intervalId = window.setInterval(() => { this.socketLoop() }, 50); } socketLoop(): void { @@ -171,8 +170,6 @@ export class SimpleReactPyClient if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { logger.warn("Closing socket connection due to idle activity"); this.socket.current.close(); - if (this.intervalId) - clearInterval(this.intervalId); } } } @@ -182,9 +179,14 @@ export class SimpleReactPyClient readyPromise: this.ready, url: this.urls.stream, onOpen: onOpen, + onClose: () => { + if (this.socketLoopIntervalId) + clearInterval(this.socketLoopIntervalId); + }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, ...this.reconnectOptions, }); + this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, 50); } ensureConnected(): void { @@ -202,13 +204,6 @@ export class SimpleReactPyClient loadModule(moduleName: string): Promise { return import(`${this.urls.modules}/${moduleName}`); } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } } type ServerUrls = { From 5c03f17bfb9570b9086ecd2d39dab6481739ee04 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Wed, 28 Feb 2024 23:39:49 +0000 Subject: [PATCH 009/166] have client track if its sleeping --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index ecc118138..3f235d8d1 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -135,6 +135,7 @@ export class SimpleReactPyClient private forceRerender: boolean; private messageQueue: any[] = []; private socketLoopIntervalId?: number | null; + private sleeping: boolean; constructor(props: SimpleReactPyClientProps) { super(); @@ -150,6 +151,7 @@ export class SimpleReactPyClient this.forceRerender = props.forceRerender !== undefined ? props.forceRerender : false; this.lastMessageTime = Date.now() this.reconnectOptions = props.reconnectOptions + this.sleeping = false; this.reconnect() } @@ -169,6 +171,7 @@ export class SimpleReactPyClient if (Date.now() - this.lastMessageTime > this.idleDisconnectTimeMillis) { if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { logger.warn("Closing socket connection due to idle activity"); + this.sleeping = true; this.socket.current.close(); } } @@ -182,6 +185,9 @@ export class SimpleReactPyClient onClose: () => { if (this.socketLoopIntervalId) clearInterval(this.socketLoopIntervalId); + if (!this.sleeping) { + this.reconnect(onOpen); + } }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, ...this.reconnectOptions, @@ -198,6 +204,7 @@ export class SimpleReactPyClient sendMessage(message: any): void { this.messageQueue.push(message); this.lastMessageTime = Date.now() + this.sleeping = false; this.ensureConnected(); } From 845e5d4661f294f1722c172f289e87f1bf1ddb91 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 00:11:09 +0000 Subject: [PATCH 010/166] WIP reconnecting --- .../@reactpy/client/src/reactpy-client.ts | 10 ++++ src/py/reactpy/reactpy/backend/hooks.py | 11 +++- .../reactpy/reactpy/core/_life_cycle_hook.py | 3 + src/py/reactpy/reactpy/core/hooks.py | 13 ++++- src/py/reactpy/reactpy/core/serve.py | 56 +++++++++++++------ 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 3f235d8d1..9852bdd24 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -136,6 +136,7 @@ export class SimpleReactPyClient private messageQueue: any[] = []; private socketLoopIntervalId?: number | null; private sleeping: boolean; + private isReconnecting: boolean; constructor(props: SimpleReactPyClientProps) { super(); @@ -152,10 +153,18 @@ export class SimpleReactPyClient this.lastMessageTime = Date.now() this.reconnectOptions = props.reconnectOptions this.sleeping = false; + this.isReconnecting = false; + + this.onMessage("reconnecting-check", () => { this.indicateReconnect() }) this.reconnect() } + indicateReconnect(): void { + const isReconnecting = this.isReconnecting ? "yes" : "no"; + this.sendMessage({ "type": "reconnecting-check", "value": isReconnecting }) + } + socketLoop(): void { if (!this.socket) return; @@ -183,6 +192,7 @@ export class SimpleReactPyClient url: this.urls.stream, onOpen: onOpen, onClose: () => { + this.isReconnecting = true; if (this.socketLoopIntervalId) clearInterval(this.socketLoopIntervalId); if (!this.sleeping) { diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index ee4ce1b5c..15ece0cb3 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections.abc import MutableMapping -from typing import Any +from typing import Any, Callable from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import create_context, use_context +from reactpy.core.hooks import ReconnectingOnly, create_context, use_context, use_effect, _EffectApplyFunc from reactpy.core.types import Context # backend implementations should establish this context at the root of an app @@ -28,3 +28,10 @@ def use_scope() -> MutableMapping[str, Any]: def use_location() -> Location: """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" return use_connection().location + + +def use_reconnect_effect( + function: _EffectApplyFunc | None = None, +) -> Callable[[_EffectApplyFunc], None] | None: + """Apply an effect only on reconnection""" + return use_effect(function, ReconnectingOnly) \ No newline at end of file diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 88d3386a8..de13d6f24 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -117,6 +117,7 @@ async def my_effect(stop_event): "_scheduled_render", "_state", "component", + "reconnecting" ) component: ComponentType @@ -124,6 +125,7 @@ async def my_effect(stop_event): def __init__( self, schedule_render: Callable[[], None], + reconnecting: bool ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -135,6 +137,7 @@ def __init__( self._effect_tasks: list[Task[None]] = [] self._effect_stops: list[Event] = [] self._render_access = Semaphore(1) # ensure only one render at a time + self.reconnecting = reconnecting def schedule_render(self) -> None: if self._scheduled_render: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 640cbf14c..b1e0b9a34 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -38,6 +38,11 @@ logger = getLogger(__name__) + +class ReconnectingOnly: + """Class for when an effect should only be applied on reconnection to the server""" + + _Type = TypeVar("_Type") @@ -132,8 +137,12 @@ def use_effect( If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - - dependencies = _try_to_infer_closure_values(function, dependencies) + if hook.reconnecting: + if dependencies is not ReconnectingOnly: + return + dependencies = None + else: + dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 314e40e1a..bc55a3ff2 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -7,9 +7,12 @@ from anyio import create_task_group from anyio.abc import TaskGroup +from reactpy.backend.hooks import ConnectionContext +from reactpy.backend.types import Connection from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage +from reactpy.core.layout import Layout +from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor logger = getLogger(__name__) @@ -83,19 +86,40 @@ async def _single_incoming_loop( task_group.start_soon(layout.deliver, await recv()) -async def handshake( - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - await send({"type": "reconnecting-check"}) - result = await recv() - if result['type'] == "reconnecting-check": - if result["value"] == "yes": - await do_reconnection(send, recv) - +class WebsocketServer: + def __init__(self, send: SendCoroutine, recv: RecvCoroutine) -> None: + self._send = send + self._recv = recv -async def do_reconnection( - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - pass + async def handle_connection(self, connection: Connection, constructor: RootComponentConstructor): + await self._handshake() + await serve_layout( + Layout( + ConnectionContext( + constructor(), + value=connection, + ) + ), + self._send, + self._recv, + ) + + async def _handshake( + self, + ) -> None: + await self._send({"type": "reconnecting-check"}) + result = await self._recv() + if result['type'] == "reconnecting-check": + if result["value"] == "yes": + logger.info("Handshake: Doing state rebuild for reconnection") + await self._do_state_rebuild_for_reconnection() + else: + logger.info("Handshake: new connection") + else: + logger.warning(f"Unexpected type when expecting reconnecting-check: {result['type']}") + + + async def _do_state_rebuild_for_reconnection( + self, + ) -> None: + pass From 7ecf4d2662f612ed47f2663337b36713e15d1641 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 00:24:45 +0000 Subject: [PATCH 011/166] add reconnecting arg --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 70bdbbbff..efb20d712 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -668,7 +668,7 @@ def _make_life_cycle_state( life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( life_cycle_state_id, - LifeCycleHook(lambda: schedule_render(life_cycle_state_id)), + LifeCycleHook(lambda: schedule_render(life_cycle_state_id), reconnecting=False), component, ) From c56746e70d7255e6067f0cdd292a4132b66373d2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 00:29:17 +0000 Subject: [PATCH 012/166] Change sanic to do handle_connection --- src/py/reactpy/reactpy/backend/sanic.py | 46 ++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 76eb0423e..7d87c826d 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -28,7 +28,7 @@ from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.types import Connection, Location from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout +from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, WebsocketServer, serve_layout from reactpy.core.types import RootComponentConstructor logger = logging.getLogger(__name__) @@ -171,27 +171,33 @@ async def model_stream( logger.warning("No scope. Sanic may not be running with an ASGI server") send, recv = _make_send_recv_callbacks(socket) - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=Connection( - scope=scope, - location=Location( - pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), - ), - carrier=_SanicCarrier(request, socket), + + # TODO: reconnecting handshake + server = WebsocketServer(send, recv) + await server.handle_connection( + Connection( + scope=scope, + location=Location( + pathname=f"/{path[len(options.url_prefix):]}", + search=( + f"?{request.query_string}" + if request.query_string + else "" ), - ) - ), - send, - recv, + ), + carrier=_SanicCarrier(request, socket), + ), constructor ) + # await serve_layout( + # Layout( + # ConnectionContext( + # constructor(), + # value=, + # ) + # ), + # send, + # recv, + # ) api_blueprint.add_websocket_route( model_stream, From 6225cf19b00fcdf1023ad4ad9e1457923c71dcee Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 00:46:41 +0000 Subject: [PATCH 013/166] is ready controls --- .../@reactpy/client/src/reactpy-client.ts | 31 +++++++++++++++---- src/py/reactpy/reactpy/core/serve.py | 9 ++++-- src/py/reactpy/reactpy/core/types.py | 6 ++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 9852bdd24..8b79a430b 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -123,6 +123,11 @@ type ReconnectProps = { intervalJitter?: number; }; +enum messageTypes { + isReady = "is-ready", + reconnectingCheck = "reconnecting-check" +}; + export class SimpleReactPyClient extends BaseReactPyClient implements ReactPyClient { @@ -137,6 +142,7 @@ export class SimpleReactPyClient private socketLoopIntervalId?: number | null; private sleeping: boolean; private isReconnecting: boolean; + private isReady: boolean; constructor(props: SimpleReactPyClientProps) { super(); @@ -154,26 +160,34 @@ export class SimpleReactPyClient this.reconnectOptions = props.reconnectOptions this.sleeping = false; this.isReconnecting = false; + this.isReady = false - this.onMessage("reconnecting-check", () => { this.indicateReconnect() }) + this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) + this.onMessage(messageTypes.isReady, () => { this.isReady = true }); this.reconnect() } indicateReconnect(): void { const isReconnecting = this.isReconnecting ? "yes" : "no"; - this.sendMessage({ "type": "reconnecting-check", "value": isReconnecting }) + this.sendMessage({ "type": messageTypes.reconnectingCheck, "value": isReconnecting }, true) } socketLoop(): void { if (!this.socket) return; - if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN && this.messageQueue.length > 0) { + if (this.messageQueue.length > 0 && this.isReady && this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { const message = this.messageQueue.shift(); // Remove the first message from the queue + this.transmitMessage(message); + } + this.idleTimeoutCheck(); + } + + transmitMessage(message: any): void { + if (this.socket && this.socket.current) { logger.log("Sending message", message); this.socket.current.send(JSON.stringify(message)); } - this.idleTimeoutCheck(); } idleTimeoutCheck(): void { @@ -193,6 +207,7 @@ export class SimpleReactPyClient onOpen: onOpen, onClose: () => { this.isReconnecting = true; + this.isReady = false; if (this.socketLoopIntervalId) clearInterval(this.socketLoopIntervalId); if (!this.sleeping) { @@ -211,8 +226,12 @@ export class SimpleReactPyClient } } - sendMessage(message: any): void { - this.messageQueue.push(message); + sendMessage(message: any, immediate: boolean = false): void { + if (immediate) { + this.transmitMessage(message); + } else { + this.messageQueue.push(message); + } this.lastMessageTime = Date.now() this.sleeping = false; this.ensureConnected(); diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index bc55a3ff2..2bead6c19 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -12,12 +12,12 @@ from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.layout import Layout -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor +from reactpy.core.types import IsReadyMessage, LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage | IsReadyMessage], Awaitable[None]] """Send model patches given by a dispatcher""" RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | ReconnectingCheckMessage]] @@ -103,11 +103,12 @@ async def handle_connection(self, connection: Connection, constructor: RootCompo self._send, self._recv, ) + await self._indicate_ready() async def _handshake( self, ) -> None: - await self._send({"type": "reconnecting-check"}) + await self._send(ReconnectingCheckMessage(type="reconnecting-check")) result = await self._recv() if result['type'] == "reconnecting-check": if result["value"] == "yes": @@ -118,6 +119,8 @@ async def _handshake( else: logger.warning(f"Unexpected type when expecting reconnecting-check: {result['type']}") + async def _indicate_ready(self) -> None: + await self._send(IsReadyMessage(type="is-ready")) async def _do_state_rebuild_for_reconnection( self, diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index e32e4f952..3a4f49bff 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -223,6 +223,12 @@ class ReconnectingCheckMessage(TypedDict): value: Literal["yes", "no"] +class IsReadyMessage(TypedDict): + """Indicate server is ready for client events""" + + type: Literal["is-ready"] + + class LayoutEventMessage(TypedDict): """Message describing an event originating from an element in the layout""" From f25dbd7f404dc05fdefea892b1b41d9e52939f7c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 00:49:06 +0000 Subject: [PATCH 014/166] move indicate ready --- src/py/reactpy/reactpy/core/serve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 2bead6c19..84b16362c 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -103,7 +103,6 @@ async def handle_connection(self, connection: Connection, constructor: RootCompo self._send, self._recv, ) - await self._indicate_ready() async def _handshake( self, @@ -118,6 +117,7 @@ async def _handshake( logger.info("Handshake: new connection") else: logger.warning(f"Unexpected type when expecting reconnecting-check: {result['type']}") + await self._indicate_ready() async def _indicate_ready(self) -> None: await self._send(IsReadyMessage(type="is-ready")) From 49c849d52736d9505db38663c8b5ed6145b3bc6d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 01:46:01 +0000 Subject: [PATCH 015/166] give states a key --- src/py/reactpy/reactpy/core/hooks.py | 15 ++++++++++++++- src/py/reactpy/reactpy/core/layout.py | 10 +++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index b1e0b9a34..b584a8299 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -2,7 +2,10 @@ import asyncio from collections.abc import Coroutine, Sequence +from hashlib import md5 +import inspect from logging import getLogger +import sys from types import FunctionType from typing import ( TYPE_CHECKING, @@ -66,17 +69,27 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: Returns: A tuple containing the current state and a function to update it. """ - current_state = _use_const(lambda: _CurrentState(initial_value)) + caller_info = get_caller_info() + current_state = _use_const(lambda: _CurrentState(md5(caller_info.encode()).hexdigest(), initial_value)) return State(current_state.value, current_state.dispatch) +def get_caller_info(): + # Get the current stack frame and then the frame above it + caller_frame = sys._getframe(2) + # Extract the relevant information: file path and line number + return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno}" + + class _CurrentState(Generic[_Type]): __slots__ = "value", "dispatch" def __init__( self, + key: str, initial_value: _Type | Callable[[], _Type], ) -> None: + self.key = key if callable(initial_value): self.value = initial_value() else: diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index efb20d712..07b00a367 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -167,11 +167,11 @@ async def _create_layout_update( if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) - return { - "type": "layout-update", - "path": new_state.patch_path, - "model": new_state.model.current, - } + return LayoutUpdateMessage( + type="layout-update", + path=new_state.patch_path, + model=new_state.model.current, + ) async def _render_component( self, From 17780e21f1e39df45908f6f1947c1ebbf3ef2ebb Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 01:58:00 +0000 Subject: [PATCH 016/166] server_only and send over state vars --- src/py/reactpy/reactpy/core/hooks.py | 14 +++++++++----- src/py/reactpy/reactpy/core/layout.py | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index b584a8299..fa0675d58 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -57,7 +57,7 @@ def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ... def use_state(initial_value: _Type) -> State[_Type]: ... -def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: +def use_state(initial_value: _Type | Callable[[], _Type], *, server_only: bool = False) -> State[_Type]: """See the full :ref:`Use State` docs for details Parameters: @@ -69,8 +69,12 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: Returns: A tuple containing the current state and a function to update it. """ - caller_info = get_caller_info() - current_state = _use_const(lambda: _CurrentState(md5(caller_info.encode()).hexdigest(), initial_value)) + if server_only: + key = None + else: + caller_info = get_caller_info() + key = md5(caller_info.encode()).hexdigest() + current_state = _use_const(lambda: _CurrentState(key, initial_value)) return State(current_state.value, current_state.dispatch) @@ -82,11 +86,11 @@ def get_caller_info(): class _CurrentState(Generic[_Type]): - __slots__ = "value", "dispatch" + __slots__ = "key", "value", "dispatch" def __init__( self, - key: str, + key: str | None, initial_value: _Type | Callable[[], _Type], ) -> None: self.key = key diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 07b00a367..fed2eca72 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -171,6 +171,9 @@ async def _create_layout_update( type="layout-update", path=new_state.patch_path, model=new_state.model.current, + state_vars={ + x.key: x.value for x in new_state.life_cycle_state.hook._state if getattr(x, "key", None) + } ) async def _render_component( From 4303577a210b2ec3e833fec0eba6d6c63c6644d1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 02:01:54 +0000 Subject: [PATCH 017/166] simplify for testing --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index fed2eca72..6cb36e785 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -172,7 +172,7 @@ async def _create_layout_update( path=new_state.patch_path, model=new_state.model.current, state_vars={ - x.key: x.value for x in new_state.life_cycle_state.hook._state if getattr(x, "key", None) + x.key: x.value for x in new_state.life_cycle_state.hook._state if getattr(x, "key", None) and isinstance(x.value, str) } ) From d935805f47e8b59887b38971951e7233410814a1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 02:18:40 +0000 Subject: [PATCH 018/166] inital work client taking in statevars --- src/js/packages/@reactpy/client/src/components.tsx | 3 ++- .../packages/@reactpy/client/src/reactpy-client.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 499f00150..266bf9d38 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -29,12 +29,13 @@ export function Layout(props: { client: ReactPyClient }): JSX.Element { useEffect( () => - props.client.onMessage("layout-update", ({ path, model }) => { + props.client.onMessage("layout-update", ({ path, model, stateVars }) => { if (path === "") { Object.assign(currentModel, model); } else { setJsonPointer(currentModel, path, model); } + props.client.updateStateVars(stateVars); 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 8b79a430b..b776a9087 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -29,16 +29,24 @@ export interface ReactPyClient { * @returns A promise that resolves to the module. */ loadModule(moduleName: string): Promise; + + /** + * Update state vars from the server for reconnections + * @param givenStateVars State vars to store + */ + updateStateVars(givenStateVars: object): void; } export abstract class BaseReactPyClient implements ReactPyClient { private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; protected readonly ready: Promise; private resolveReady: (value: undefined) => void; + protected stateVars: object; constructor() { this.resolveReady = () => { }; this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + this.stateVars = {}; } onMessage(type: string, handler: (message: any) => void): () => void { @@ -52,6 +60,11 @@ export abstract class BaseReactPyClient implements ReactPyClient { abstract sendMessage(message: any): void; abstract loadModule(moduleName: string): Promise; + updateStateVars(givenStateVars: object): void { + Object.assign(this.stateVars, givenStateVars); + logger.log(this.stateVars); + } + /** * Handle an incoming message. * From dc67af02a8d65e46f0a5579c08c297147d016591 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 02:55:31 +0000 Subject: [PATCH 019/166] WIP client_state (python side) --- .../@reactpy/client/src/reactpy-client.ts | 13 +++- .../reactpy/reactpy/core/_life_cycle_hook.py | 7 +- src/py/reactpy/reactpy/core/hooks.py | 4 ++ src/py/reactpy/reactpy/core/layout.py | 17 +++-- src/py/reactpy/reactpy/core/serve.py | 68 +++++++++++-------- src/py/reactpy/reactpy/core/types.py | 9 +++ 6 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index b776a9087..76e1568e0 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -138,7 +138,8 @@ type ReconnectProps = { enum messageTypes { isReady = "is-ready", - reconnectingCheck = "reconnecting-check" + reconnectingCheck = "reconnecting-check", + clientState = "client-state" }; export class SimpleReactPyClient @@ -177,6 +178,7 @@ export class SimpleReactPyClient this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) this.onMessage(messageTypes.isReady, () => { this.isReady = true }); + this.onMessage(messageTypes.clientState, () => { this.sendClientState() }); this.reconnect() } @@ -186,6 +188,15 @@ export class SimpleReactPyClient this.sendMessage({ "type": messageTypes.reconnectingCheck, "value": isReconnecting }, true) } + sendClientState(): void { + if (!this.socket) + return; + this.transmitMessage({ + "type": "client-state", + "value": this.stateVars + }); + } + socketLoop(): void { if (!this.socket) return; diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index de13d6f24..fa7cfaa10 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -117,7 +117,8 @@ async def my_effect(stop_event): "_scheduled_render", "_state", "component", - "reconnecting" + "reconnecting", + "client_state" ) component: ComponentType @@ -125,7 +126,8 @@ async def my_effect(stop_event): def __init__( self, schedule_render: Callable[[], None], - reconnecting: bool + reconnecting: bool, + client_state: dict[str, Any] ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -138,6 +140,7 @@ def __init__( self._effect_stops: list[Event] = [] self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting + self._client_state = client_state or {} def schedule_render(self) -> None: if self._scheduled_render: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index fa0675d58..e6deab4c8 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -74,6 +74,10 @@ def use_state(initial_value: _Type | Callable[[], _Type], *, server_only: bool = else: caller_info = get_caller_info() key = md5(caller_info.encode()).hexdigest() + hook = current_hook() + if hook.reconnecting: + # TODO: if key is missing, maybe raise exception and abort recovery? + initial_value = hook.client_state.get(key, initial_value) current_state = _use_const(lambda: _CurrentState(key, initial_value)) return State(current_state.value, current_state.dispatch) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 6cb36e785..149b0aa86 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -62,6 +62,8 @@ class Layout: "_render_tasks_ready", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", + "reconnecting", + "client_state" ) if not hasattr(abc.ABC, "__weakref__"): # nocov @@ -73,6 +75,8 @@ def __init__(self, root: ComponentType) -> None: msg = f"Expected a ComponentType, not {type(root)!r}." raise TypeError(msg) self.root = root + self.reconnecting = False + self.client_state = {} async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager @@ -81,11 +85,12 @@ async def __aenter__(self) -> Layout: self._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._schedule_render_task) + root_model_state = _new_root_model_state(self.root, self._schedule_render_task, self.reconnecting) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} - self._schedule_render_task(root_id) + # TODO: what to do with this? + # self._schedule_render_task(root_id) return self @@ -483,7 +488,7 @@ def __repr__(self) -> str: def _new_root_model_state( - component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None] + component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any] ) -> _ModelState: return _ModelState( parent=None, @@ -493,7 +498,7 @@ def _new_root_model_state( patch_path="", children_by_key={}, targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render), + life_cycle_state=_make_life_cycle_state(component, schedule_render, reconnecting, client_state), ) @@ -667,11 +672,13 @@ def __repr__(self) -> str: # nocov def _make_life_cycle_state( component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], + reconnecting: bool, + client_state: dict[str, Any] ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( life_cycle_state_id, - LifeCycleHook(lambda: schedule_render(life_cycle_state_id), reconnecting=False), + LifeCycleHook(lambda: schedule_render(life_cycle_state_id), reconnecting=reconnecting, client_state=client_state), component, ) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 84b16362c..1d557bb13 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -12,15 +12,15 @@ from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.layout import Layout -from reactpy.core.types import IsReadyMessage, LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor +from reactpy.core.types import ClientStateMessage, IsReadyMessage, LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage | IsReadyMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage | IsReadyMessage | ClientStateMessage], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | ReconnectingCheckMessage]] +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | ReconnectingCheckMessage | ClientStateMessage]] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -43,18 +43,18 @@ async def serve_layout( recv: RecvCoroutine, ) -> None: """Run a dispatch loop for a single view instance""" - async with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, task_group, layout, recv) - except Stop: # nocov - warn( - "The Stop exception is deprecated and will be removed in a future version", - UserWarning, - stacklevel=1, - ) - logger.info(f"Stopped serving {layout}") + + try: + async with create_task_group() as task_group: + task_group.start_soon(_single_outgoing_loop, layout, send) + task_group.start_soon(_single_incoming_loop, task_group, layout, recv) + except Stop: # nocov + warn( + "The Stop exception is deprecated and will be removed in a future version", + UserWarning, + stacklevel=1, + ) + logger.info(f"Stopped serving {layout}") async def _single_outgoing_loop( @@ -92,27 +92,30 @@ def __init__(self, send: SendCoroutine, recv: RecvCoroutine) -> None: self._recv = recv async def handle_connection(self, connection: Connection, constructor: RootComponentConstructor): - await self._handshake() - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=connection, - ) - ), - self._send, - self._recv, + layout= Layout( + ConnectionContext( + constructor(), + value=connection, + ) ) + async with layout: + await self._handshake(layout) + await serve_layout( + layout, + self._send, + self._recv, + ) async def _handshake( self, + layout: Layout ) -> None: await self._send(ReconnectingCheckMessage(type="reconnecting-check")) result = await self._recv() if result['type'] == "reconnecting-check": if result["value"] == "yes": logger.info("Handshake: Doing state rebuild for reconnection") - await self._do_state_rebuild_for_reconnection() + await self._do_state_rebuild_for_reconnection(layout) else: logger.info("Handshake: new connection") else: @@ -124,5 +127,16 @@ async def _indicate_ready(self) -> None: async def _do_state_rebuild_for_reconnection( self, + layout: Layout ) -> None: - pass + await self._send(ClientStateMessage(type="client-state")) + client_state_msg = await self._recv() + if client_state_msg["type"] != "client-state": + logger.warning(f"Unexpected type when expecting client-state: {client_state_msg['type']}") + return + client_state = client_state_msg["value"] + layout.reconnecting = True + layout.client_state = client_state + await layout.render() + layout.reconnecting = False + layout.client_state = {} diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 3a4f49bff..6dde4c2f9 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -213,6 +213,7 @@ class LayoutUpdateMessage(TypedDict): """JSON Pointer path to the model element being updated""" model: VdomJson """The model to assign at the given JSON Pointer path""" + state_vars: dict[str, Any] class ReconnectingCheckMessage(TypedDict): @@ -223,6 +224,14 @@ class ReconnectingCheckMessage(TypedDict): value: Literal["yes", "no"] +class ClientStateMessage(TypedDict): + """A message requesting the current state of the client""" + + type: Literal["client-state"] + """The type of message""" + value: dict[str, Any] + + class IsReadyMessage(TypedDict): """Indicate server is ready for client events""" From 4f8111f63a8911eff2a7ccf781b73df502ecf207 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 03:02:08 +0000 Subject: [PATCH 020/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 5 ++++- src/py/reactpy/reactpy/core/serve.py | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index fa7cfaa10..bdfee46fd 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -140,7 +140,7 @@ def __init__( self._effect_stops: list[Event] = [] self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting - self._client_state = client_state or {} + self.client_state = client_state or {} def schedule_render(self) -> None: if self._scheduled_render: diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 149b0aa86..f78632547 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -85,7 +85,7 @@ async def __aenter__(self) -> Layout: self._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._schedule_render_task, self.reconnecting) + root_model_state = _new_root_model_state(self.root, self._schedule_render_task, self.reconnecting, self.client_state) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} @@ -113,6 +113,9 @@ async def __aexit__(self, *exc: Any) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id + def start_rendering(self) -> None: + self._schedule_render_task(self._root_life_cycle_state_id) + async def deliver(self, event: LayoutEventMessage) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 1d557bb13..995a604b7 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -118,6 +118,7 @@ async def _handshake( await self._do_state_rebuild_for_reconnection(layout) else: logger.info("Handshake: new connection") + layout.start_rendering() else: logger.warning(f"Unexpected type when expecting reconnecting-check: {result['type']}") await self._indicate_ready() @@ -137,6 +138,7 @@ async def _do_state_rebuild_for_reconnection( client_state = client_state_msg["value"] layout.reconnecting = True layout.client_state = client_state + layout.start_rendering() await layout.render() layout.reconnecting = False layout.client_state = {} From 68272fdce21ea55d5b10a19e81d93c6cf804f54a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 03:05:56 +0000 Subject: [PATCH 021/166] wip --- src/py/reactpy/reactpy/core/layout.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f78632547..dfe404ad3 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -396,6 +396,8 @@ async def _render_model_children( key, child, self._schedule_render_task, + self.reconnecting, + self.client_state ) elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type @@ -408,6 +410,8 @@ async def _render_model_children( key, child, self._schedule_render_task, + self.reconnecting, + self.client_state ) else: new_child_state = _update_component_model_state( @@ -416,6 +420,8 @@ async def _render_model_children( index, child, self._schedule_render_task, + self.reconnecting, + self.client_state ) await self._render_component( exit_stack, old_child_state, new_child_state, child @@ -450,7 +456,7 @@ async def _render_model_children_without_old_state( new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( - new_state, index, key, child, self._schedule_render_task + new_state, index, key, child, self._schedule_render_task, self.reconnecting, self.client_state ) await self._render_component(exit_stack, None, child_state, child) else: @@ -511,6 +517,8 @@ def _make_component_model_state( key: Any, component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], + reconnecting: bool, + client_state: dict[str, Any] ) -> _ModelState: return _ModelState( parent=parent, @@ -520,7 +528,7 @@ def _make_component_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render), + life_cycle_state=_make_life_cycle_state(component, schedule_render, reconnecting, client_state), ) @@ -549,6 +557,8 @@ def _update_component_model_state( new_index: int, new_component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], + reconnecting: bool, + client_state: dict[str, Any] ) -> _ModelState: return _ModelState( parent=new_parent, @@ -561,7 +571,7 @@ def _update_component_model_state( life_cycle_state=( _update_life_cycle_state(old_model_state.life_cycle_state, new_component) if old_model_state.is_component_state - else _make_life_cycle_state(new_component, schedule_render) + else _make_life_cycle_state(new_component, schedule_render, reconnecting, client_state) ), ) From 8dbdc52500513c60475e2f0ccc492345e7261595 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 29 Feb 2024 03:09:35 +0000 Subject: [PATCH 022/166] deterministic target names --- src/py/reactpy/reactpy/core/layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index dfe404ad3..8e6b1d7b4 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -289,7 +289,7 @@ def _render_model_attributes( if event in old_state.targets_by_event: target = old_state.targets_by_event[event] else: - target = uuid4().hex if handler.target is None else handler.target + target = new_state.patch_path + event if handler.target is None else handler.target new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { @@ -310,7 +310,7 @@ def _render_model_event_handlers_without_old_state( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - target = uuid4().hex if handler.target is None else handler.target + target = new_state.patch_path + event if handler.target is None else handler.target new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { From c89756e6c53a35a420df5579976ad87c7c7e3f25 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 02:37:15 +0000 Subject: [PATCH 023/166] wip restoration with serializer, not yet hooked up --- pyproject.toml | 2 + src/py/reactpy/reactpy/core/hooks.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 74 ++++++-- src/py/reactpy/reactpy/core/serve.py | 77 +++++--- src/py/reactpy/reactpy/core/state_recovery.py | 176 ++++++++++++++++++ src/py/reactpy/reactpy/core/types.py | 3 + 6 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 src/py/reactpy/reactpy/core/state_recovery.py diff --git a/pyproject.toml b/pyproject.toml index 775ab01a2..d03233be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "semver >=2, <3", "twine", "pre-commit", + "pyotp", + "orjson", ] [tool.hatch.envs.default.scripts] diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index e6deab4c8..120eec5fc 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -73,7 +73,7 @@ def use_state(initial_value: _Type | Callable[[], _Type], *, server_only: bool = key = None else: caller_info = get_caller_info() - key = md5(caller_info.encode()).hexdigest() + key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() hook = current_hook() if hook.reconnecting: # TODO: if key is missing, maybe raise exception and abort recovery? diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 8e6b1d7b4..279006e06 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -35,6 +35,7 @@ REACTPY_DEBUG_MODE, ) from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core.state_recovery import StateRecoveryManager, StateRecoverySerializer from reactpy.core.types import ( ComponentType, EventHandlerDict, @@ -63,19 +64,23 @@ class Layout: "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", "reconnecting", - "client_state" + "client_state", + "_state_recovery_serializer", ) if not hasattr(abc.ABC, "__weakref__"): # nocov __slots__ += ("__weakref__",) - def __init__(self, root: ComponentType) -> None: + def __init__( + self, root: ComponentType, state_recovery_serializer: StateRecoverySerializer + ) -> None: super().__init__() if not isinstance(root, ComponentType): msg = f"Expected a ComponentType, not {type(root)!r}." raise TypeError(msg) self.root = root self.reconnecting = False + self._state_recovery_serializer = state_recovery_serializer self.client_state = {} async def __aenter__(self) -> Layout: @@ -85,7 +90,9 @@ async def __aenter__(self) -> Layout: self._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._schedule_render_task, self.reconnecting, self.client_state) + root_model_state = _new_root_model_state( + self.root, self._schedule_render_task, self.reconnecting, self.client_state + ) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} @@ -179,9 +186,9 @@ async def _create_layout_update( type="layout-update", path=new_state.patch_path, model=new_state.model.current, - state_vars={ - x.key: x.value for x in new_state.life_cycle_state.hook._state if getattr(x, "key", None) and isinstance(x.value, str) - } + state_vars=self._state_recovery_serializer.serialize_state_vars( + new_state.life_cycle_state.hook._state + ), ) async def _render_component( @@ -289,7 +296,11 @@ def _render_model_attributes( if event in old_state.targets_by_event: target = old_state.targets_by_event[event] else: - target = new_state.patch_path + event if handler.target is None else handler.target + target = ( + new_state.patch_path + event + if handler.target is None + else handler.target + ) new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { @@ -310,7 +321,11 @@ def _render_model_event_handlers_without_old_state( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - target = new_state.patch_path + event if handler.target is None else handler.target + target = ( + new_state.patch_path + event + if handler.target is None + else handler.target + ) new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { @@ -397,7 +412,7 @@ async def _render_model_children( child, self._schedule_render_task, self.reconnecting, - self.client_state + self.client_state, ) elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type @@ -411,7 +426,7 @@ async def _render_model_children( child, self._schedule_render_task, self.reconnecting, - self.client_state + self.client_state, ) else: new_child_state = _update_component_model_state( @@ -421,7 +436,7 @@ async def _render_model_children( child, self._schedule_render_task, self.reconnecting, - self.client_state + self.client_state, ) await self._render_component( exit_stack, old_child_state, new_child_state, child @@ -456,7 +471,13 @@ async def _render_model_children_without_old_state( new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( - new_state, index, key, child, self._schedule_render_task, self.reconnecting, self.client_state + new_state, + index, + key, + child, + self._schedule_render_task, + self.reconnecting, + self.client_state, ) await self._render_component(exit_stack, None, child_state, child) else: @@ -497,7 +518,10 @@ def __repr__(self) -> str: def _new_root_model_state( - component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any] + component: ComponentType, + schedule_render: Callable[[_LifeCycleStateId], None], + reconnecting: bool, + client_state: dict[str, Any], ) -> _ModelState: return _ModelState( parent=None, @@ -507,7 +531,9 @@ def _new_root_model_state( patch_path="", children_by_key={}, targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render, reconnecting, client_state), + life_cycle_state=_make_life_cycle_state( + component, schedule_render, reconnecting, client_state + ), ) @@ -518,7 +544,7 @@ def _make_component_model_state( component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, - client_state: dict[str, Any] + client_state: dict[str, Any], ) -> _ModelState: return _ModelState( parent=parent, @@ -528,7 +554,9 @@ def _make_component_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render, reconnecting, client_state), + life_cycle_state=_make_life_cycle_state( + component, schedule_render, reconnecting, client_state + ), ) @@ -558,7 +586,7 @@ def _update_component_model_state( new_component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, - client_state: dict[str, Any] + client_state: dict[str, Any], ) -> _ModelState: return _ModelState( parent=new_parent, @@ -571,7 +599,9 @@ def _update_component_model_state( life_cycle_state=( _update_life_cycle_state(old_model_state.life_cycle_state, new_component) if old_model_state.is_component_state - else _make_life_cycle_state(new_component, schedule_render, reconnecting, client_state) + else _make_life_cycle_state( + new_component, schedule_render, reconnecting, client_state + ) ), ) @@ -686,12 +716,16 @@ def _make_life_cycle_state( component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, - client_state: dict[str, Any] + client_state: dict[str, Any], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( life_cycle_state_id, - LifeCycleHook(lambda: schedule_render(life_cycle_state_id), reconnecting=reconnecting, client_state=client_state), + LifeCycleHook( + lambda: schedule_render(life_cycle_state_id), + reconnecting=reconnecting, + client_state=client_state, + ), component, ) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 995a604b7..7d6245999 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -7,20 +7,39 @@ from anyio import create_task_group from anyio.abc import TaskGroup + from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection - from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.layout import Layout -from reactpy.core.types import ClientStateMessage, IsReadyMessage, LayoutEventMessage, LayoutType, LayoutUpdateMessage, ReconnectingCheckMessage, RootComponentConstructor +from reactpy.core.state_recovery import StateRecoveryManager +from reactpy.core.types import ( + ClientStateMessage, + IsReadyMessage, + LayoutEventMessage, + LayoutType, + LayoutUpdateMessage, + ReconnectingCheckMessage, + RootComponentConstructor, +) logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage | ReconnectingCheckMessage | IsReadyMessage | ClientStateMessage], Awaitable[None]] +SendCoroutine = Callable[ + [ + LayoutUpdateMessage + | ReconnectingCheckMessage + | IsReadyMessage + | ClientStateMessage + ], + Awaitable[None], +] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | ReconnectingCheckMessage | ClientStateMessage]] +RecvCoroutine = Callable[ + [], Awaitable[LayoutEventMessage | ReconnectingCheckMessage | ClientStateMessage] +] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -87,12 +106,20 @@ async def _single_incoming_loop( class WebsocketServer: - def __init__(self, send: SendCoroutine, recv: RecvCoroutine) -> None: + def __init__( + self, + send: SendCoroutine, + recv: RecvCoroutine, + state_recovery_manager: StateRecoveryManager | None = None, + ) -> None: self._send = send self._recv = recv + self._state_recovery_manager = state_recovery_manager - async def handle_connection(self, connection: Connection, constructor: RootComponentConstructor): - layout= Layout( + async def handle_connection( + self, connection: Connection, constructor: RootComponentConstructor + ): + layout = Layout( ConnectionContext( constructor(), value=connection, @@ -106,36 +133,44 @@ async def handle_connection(self, connection: Connection, constructor: RootCompo self._recv, ) - async def _handshake( - self, - layout: Layout - ) -> None: + async def _handshake(self, layout: Layout) -> None: await self._send(ReconnectingCheckMessage(type="reconnecting-check")) result = await self._recv() - if result['type'] == "reconnecting-check": + if result["type"] == "reconnecting-check": if result["value"] == "yes": - logger.info("Handshake: Doing state rebuild for reconnection") - await self._do_state_rebuild_for_reconnection(layout) + if self._state_recovery_manager is None: + logger.warning( + "Reconnection detected, but no state recovery manager provided" + ) + layout.start_rendering() + else: + logger.info("Handshake: Doing state rebuild for reconnection") + await self._do_state_rebuild_for_reconnection(layout) else: logger.info("Handshake: new connection") layout.start_rendering() else: - logger.warning(f"Unexpected type when expecting reconnecting-check: {result['type']}") + logger.warning( + f"Unexpected type when expecting reconnecting-check: {result['type']}" + ) await self._indicate_ready() async def _indicate_ready(self) -> None: await self._send(IsReadyMessage(type="is-ready")) - async def _do_state_rebuild_for_reconnection( - self, - layout: Layout - ) -> None: + async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> None: await self._send(ClientStateMessage(type="client-state")) client_state_msg = await self._recv() if client_state_msg["type"] != "client-state": - logger.warning(f"Unexpected type when expecting client-state: {client_state_msg['type']}") + logger.warning( + f"Unexpected type when expecting client-state: {client_state_msg['type']}" + ) return - client_state = client_state_msg["value"] + state_vars = client_state_msg["value"] + serializer = self._state_recovery_manager.create_serializer( + client_state_msg["salt"] + ) + client_state = serializer.deserialize_client_state(state_vars) layout.reconnecting = True layout.client_state = client_state layout.start_rendering() diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py new file mode 100644 index 000000000..ebc91ad96 --- /dev/null +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -0,0 +1,176 @@ +import base64 +import datetime +import hashlib +import time +from collections.abc import Iterable +from decimal import Decimal +from pathlib import Path +from typing import Any, Callable + +import orjson +import pyotp + + +class StateRecoveryFailureError(Exception): + """ + Raised when state recovery fails. + """ + + +class StateRecoveryManager: + def __init__( + self, + serializable_objects: Iterable[type], + pepper: str, + otp_key: str | None = None, + otp_interval=(4 * 60 * 60), + max_objects=1024, + max_object_length=512, + custom_serializer: Callable[[Any], bytes] | None = None, + ) -> None: + self._pepper = pepper.encode("utf-8") + self._max_objects = max_objects + self._max_object_length = max_object_length + self._otp_key = base64.b32encode( + (otp_key or self._discover_otp_key()).encode("utf-8") + ) + self._totp = pyotp.TOTP(self._otp_key, interval=otp_interval) + self._custom_serializer = custom_serializer + + self._map_objects_to_ids( + [ + *list(serializable_objects), + Decimal, + datetime.datetime, + datetime.date, + datetime.time, + ] + ) + + def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: + self._object_to_id = { + obj: str(id(obj)).encode("utf-8") for obj in serializable_objects + } + self._type_id_to_object = { + str(id(obj)).encode("utf-8"): obj for obj in serializable_objects + } + + self._object_to_id[None] = b"0" + self._type_id_to_object[b"0"] = None + + self._object_to_id[str] = b"1" + self._type_id_to_object[b"1"] = str + + self._object_to_id[int] = b"2" + self._type_id_to_object[b"2"] = int + + self._object_to_id[float] = b"3" + self._type_id_to_object[b"3"] = float + + self._object_to_id[bool] = b"4" + self._type_id_to_object[b"4"] = bool + + def _discover_otp_key(self) -> str: + hasher = hashlib.sha256() + parent_dir_of_root = Path(__file__).parent.parent.parent + for thing in parent_dir_of_root.iterdir(): + hasher.update((thing.name + str(thing.stat().st_ctime)).encode("utf-8")) + return hasher.hexdigest() + + def create_serializer( + self, salt: str, target_time: float | None = None + ) -> "StateRecoverySerializer": + return StateRecoverySerializer( + otp_code=self._totp.at(target_time or time.time()), + pepper=self._pepper, + salt=salt, + object_to_type_id=self._object_to_id, + type_id_to_object=self._type_id_to_object, + custom_serializer=self._custom_serializer, + ) + + +class StateRecoverySerializer: + + def __init__( + self, + otp_code: str, + pepper: str, + salt: str, + object_to_type_id: dict[Any, bytes], + type_id_to_object: dict[bytes, Any], + max_object_length: int, + custom_serializer: Callable[[Any], bytes] | None = None, + ) -> None: + self._otp_code = otp_code.encode("utf-8") + self._pepper = pepper.encode("utf-8") + self._salt = salt.encode("utf-8") + self._object_to_type_id = object_to_type_id + self._type_id_to_object = type_id_to_object + self._max_object_length = max_object_length + self._custom_serializer = custom_serializer + + def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: + result = {} + for var in state_vars: + state_key = getattr(var, "key", None) + if state_key is not None: + serialized_value, signature = self._serialize(var.value) + result[state_key] = (serialized_value, signature) + return result + + def _serialize(self, key: str, obj: object) -> tuple[str, str]: + try: + type_id = self._object_to_type_id[obj] + except KeyError as err: + raise ValueError( + f"Object {obj} was not white-listed for serialization" + ) from err + result = self._serialize_object(obj) + if len(result) > self._max_object_length: + raise ValueError( + f"Serialized object {obj} is too long (length: {len(result)})" + ) + signature = self._sign_serialization(key, type_id, result) + return (base64.urlsafe_b64encode(result), signature) + + def deserialize_client_state(self, state_vars: dict[str, tuple[str, str]]) -> None: + return { + key: self._deserialize(key, data, signature) + for key, (data, signature) in state_vars.items() + } + + def _deserialize(self, key: str, type_id: int, data: bytes, signature: str) -> Any: + try: + typ = self._type_id_to_object[type_id] + except KeyError as err: + raise StateRecoveryFailureError(f"Unknown type id {type_id}") from err + + result = base64.urlsafe_b64decode(data) + expected_signature = self._sign_serialization(key, type_id, result) + if expected_signature != signature: + raise StateRecoveryFailureError(f"Signature mismatch for type id {type_id}") + return self._deserialize_object(typ, result) + + def _sign_serialization(self, key: str, type_id: bytes, data: bytes) -> str: + hasher = hashlib.sha256() + hasher.update(type_id) + hasher.update(data) + hasher.update(self._pepper) + hasher.update(self._otp_code) + hasher.update(self._salt) + hasher.update(key.encode("utf-8")) + return hasher.hexdigest() + + def _serialize_object(self, obj: Any) -> bytes: + return orjson.dumps(obj, default=self._custom_serializer) + + def _deserialize_object(self, typ: Any, data: bytes) -> Any: + if typ is None: + return None + result = orjson.loads(data) + if isinstance(result, str): + return typ(result) + if isinstance(result, dict): + return typ(**result) + return result diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 6dde4c2f9..3a1265717 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -230,6 +230,9 @@ class ClientStateMessage(TypedDict): type: Literal["client-state"] """The type of message""" value: dict[str, Any] + """The client state""" + salt: str + """The salt provided to the user""" class IsReadyMessage(TypedDict): From 56d9f7bc8e51710bb9c8e85bd1b642edfe44f43a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 02:46:39 +0000 Subject: [PATCH 024/166] rename custom serializer to default serializer --- src/py/reactpy/reactpy/core/state_recovery.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index ebc91ad96..64d20ce10 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -26,7 +26,7 @@ def __init__( otp_interval=(4 * 60 * 60), max_objects=1024, max_object_length=512, - custom_serializer: Callable[[Any], bytes] | None = None, + default_serializer: Callable[[Any], bytes] | None = None, ) -> None: self._pepper = pepper.encode("utf-8") self._max_objects = max_objects @@ -35,7 +35,7 @@ def __init__( (otp_key or self._discover_otp_key()).encode("utf-8") ) self._totp = pyotp.TOTP(self._otp_key, interval=otp_interval) - self._custom_serializer = custom_serializer + self._default_serializer = default_serializer self._map_objects_to_ids( [ @@ -86,7 +86,7 @@ def create_serializer( salt=salt, object_to_type_id=self._object_to_id, type_id_to_object=self._type_id_to_object, - custom_serializer=self._custom_serializer, + default_serializer=self._default_serializer, ) @@ -100,7 +100,7 @@ def __init__( object_to_type_id: dict[Any, bytes], type_id_to_object: dict[bytes, Any], max_object_length: int, - custom_serializer: Callable[[Any], bytes] | None = None, + default_serializer: Callable[[Any], bytes] | None = None, ) -> None: self._otp_code = otp_code.encode("utf-8") self._pepper = pepper.encode("utf-8") @@ -108,7 +108,7 @@ def __init__( self._object_to_type_id = object_to_type_id self._type_id_to_object = type_id_to_object self._max_object_length = max_object_length - self._custom_serializer = custom_serializer + self._default_serializer = default_serializer def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: result = {} @@ -163,7 +163,7 @@ def _sign_serialization(self, key: str, type_id: bytes, data: bytes) -> str: return hasher.hexdigest() def _serialize_object(self, obj: Any) -> bytes: - return orjson.dumps(obj, default=self._custom_serializer) + return orjson.dumps(obj, default=self._default_serializer) def _deserialize_object(self, typ: Any, data: bytes) -> Any: if typ is None: From 4a6d7c9f6fd08dc7fbdf2055e77f8b623ffec0e7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 03:00:36 +0000 Subject: [PATCH 025/166] wip --- src/py/reactpy/reactpy/core/layout.py | 14 ++++++++++---- src/py/reactpy/reactpy/core/serve.py | 10 +++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 279006e06..81a500587 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -35,7 +35,7 @@ REACTPY_DEBUG_MODE, ) from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.state_recovery import StateRecoveryManager, StateRecoverySerializer +from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( ComponentType, EventHandlerDict, @@ -72,7 +72,9 @@ class Layout: __slots__ += ("__weakref__",) def __init__( - self, root: ComponentType, state_recovery_serializer: StateRecoverySerializer + self, + root: ComponentType, + state_recovery_serializer: StateRecoverySerializer | None = None, ) -> None: super().__init__() if not isinstance(root, ComponentType): @@ -186,8 +188,12 @@ async def _create_layout_update( type="layout-update", path=new_state.patch_path, model=new_state.model.current, - state_vars=self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._state + state_vars=( + self._state_recovery_serializer.serialize_state_vars( + new_state.life_cycle_state.hook._state + ) + if self._state_recovery_serializer + else {} ), ) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 7d6245999..e4417f441 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -2,6 +2,8 @@ from collections.abc import Awaitable from logging import getLogger +import random +import string from typing import Callable from warnings import warn @@ -115,6 +117,7 @@ def __init__( self._send = send self._recv = recv self._state_recovery_manager = state_recovery_manager + self._salt = random.choices(string.ascii_letters + string.digits, k=8) async def handle_connection( self, connection: Connection, constructor: RootComponentConstructor @@ -123,7 +126,12 @@ async def handle_connection( ConnectionContext( constructor(), value=connection, - ) + ), + ( + self._state_recovery_manager.create_serializer(self._salt) + if self._state_recovery_manager + else None + ), ) async with layout: await self._handshake(layout) From 8b0bb4bc26ac5d173324d1935b15a35d47fbc4ba Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 03:06:40 +0000 Subject: [PATCH 026/166] wip --- src/py/reactpy/reactpy/backend/sanic.py | 28 ++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 7d87c826d..32e2882a6 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -24,11 +24,15 @@ safe_web_modules_dir_path, serve_with_uvicorn, ) -from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection from reactpy.backend.types import Connection, Location -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, WebsocketServer, serve_layout +from reactpy.core.serve import ( + RecvCoroutine, + SendCoroutine, + Stop, + WebsocketServer, +) +from reactpy.core.state_recovery import StateRecoveryManager from reactpy.core.types import RootComponentConstructor logger = logging.getLogger(__name__) @@ -51,6 +55,7 @@ def configure( app: Sanic[Any, Any], component: RootComponentConstructor, options: Options | None = None, + state_recovery_manager: StateRecoveryManager | None = None, ) -> None: """Configure an application instance to display the given component""" options = options or Options() @@ -59,7 +64,9 @@ def configure( api_bp = Blueprint(f"reactpy_api_{id(app)}", url_prefix=str(PATH_PREFIX)) _setup_common_routes(api_bp, spa_bp, options) - _setup_single_view_dispatcher_route(api_bp, component, options) + _setup_single_view_dispatcher_route( + api_bp, component, options, state_recovery_manager + ) app.blueprint([spa_bp, api_bp]) @@ -159,6 +166,7 @@ def _setup_single_view_dispatcher_route( api_blueprint: Blueprint, constructor: RootComponentConstructor, options: Options, + state_recovery_manager: StateRecoveryManager | None, ) -> None: async def model_stream( request: request.Request[Any, Any], @@ -172,21 +180,17 @@ async def model_stream( send, recv = _make_send_recv_callbacks(socket) - # TODO: reconnecting handshake - server = WebsocketServer(send, recv) + server = WebsocketServer(send, recv, state_recovery_manager) await server.handle_connection( Connection( scope=scope, location=Location( pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), + search=(f"?{request.query_string}" if request.query_string else ""), ), carrier=_SanicCarrier(request, socket), - ), constructor + ), + constructor, ) # await serve_layout( # Layout( From a353d42f4b985e9042388ca76d55080b03267a49 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 03:15:58 +0000 Subject: [PATCH 027/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 64d20ce10..9e570a6d6 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -23,9 +23,9 @@ def __init__( serializable_objects: Iterable[type], pepper: str, otp_key: str | None = None, - otp_interval=(4 * 60 * 60), - max_objects=1024, - max_object_length=512, + otp_interval: int = (4 * 60 * 60), + max_objects: int = 1024, + max_object_length: int = 512, default_serializer: Callable[[Any], bytes] | None = None, ) -> None: self._pepper = pepper.encode("utf-8") @@ -86,6 +86,7 @@ def create_serializer( salt=salt, object_to_type_id=self._object_to_id, type_id_to_object=self._type_id_to_object, + max_object_length=self._max_object_length, default_serializer=self._default_serializer, ) From f8044acbcfd2dae56dcdf033108fb53095199f79 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 03:53:44 +0000 Subject: [PATCH 028/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 9e570a6d6..14cce54b3 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -28,7 +28,7 @@ def __init__( max_object_length: int = 512, default_serializer: Callable[[Any], bytes] | None = None, ) -> None: - self._pepper = pepper.encode("utf-8") + self._pepper = pepper self._max_objects = max_objects self._max_object_length = max_object_length self._otp_key = base64.b32encode( @@ -71,6 +71,11 @@ def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: self._type_id_to_object[b"4"] = bool def _discover_otp_key(self) -> str: + """ + Generate an OTP key by looking at the parent directory of where + ReactPy is installed and taking down the names and creation times + of everything in there. + """ hasher = hashlib.sha256() parent_dir_of_root = Path(__file__).parent.parent.parent for thing in parent_dir_of_root.iterdir(): From c47ebebcfc2dfbd126744e48dbe602c7187b5475 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 03:59:33 +0000 Subject: [PATCH 029/166] store salt on client --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 7 +++++-- src/py/reactpy/reactpy/core/serve.py | 6 +++--- src/py/reactpy/reactpy/core/types.py | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 76e1568e0..7a53296df 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -157,6 +157,7 @@ export class SimpleReactPyClient private sleeping: boolean; private isReconnecting: boolean; private isReady: boolean; + private salt: string; constructor(props: SimpleReactPyClientProps) { super(); @@ -175,9 +176,10 @@ export class SimpleReactPyClient this.sleeping = false; this.isReconnecting = false; this.isReady = false + this.salt = ""; this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) - this.onMessage(messageTypes.isReady, () => { this.isReady = true }); + this.onMessage(messageTypes.isReady, (salt) => { this.isReady = true; this.salt = salt; }); this.onMessage(messageTypes.clientState, () => { this.sendClientState() }); this.reconnect() @@ -193,7 +195,8 @@ export class SimpleReactPyClient return; this.transmitMessage({ "type": "client-state", - "value": this.stateVars + "value": this.stateVars, + "salt": this.salt }); } diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index e4417f441..c1fac8a97 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -117,7 +117,7 @@ def __init__( self._send = send self._recv = recv self._state_recovery_manager = state_recovery_manager - self._salt = random.choices(string.ascii_letters + string.digits, k=8) + self._salt = "".join(random.choices(string.ascii_letters + string.digits, k=8)) async def handle_connection( self, connection: Connection, constructor: RootComponentConstructor @@ -161,10 +161,10 @@ async def _handshake(self, layout: Layout) -> None: logger.warning( f"Unexpected type when expecting reconnecting-check: {result['type']}" ) - await self._indicate_ready() + await self._indicate_ready(), async def _indicate_ready(self) -> None: - await self._send(IsReadyMessage(type="is-ready")) + await self._send(IsReadyMessage(type="is-ready", salt=self._salt)) async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> None: await self._send(ClientStateMessage(type="client-state")) diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 3a1265717..18e203eab 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -240,6 +240,8 @@ class IsReadyMessage(TypedDict): type: Literal["is-ready"] + salt: str + class LayoutEventMessage(TypedDict): """Message describing an event originating from an element in the layout""" From 126441f1e31bfdfd61b62b67e63300de40294dc5 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:10:23 +0000 Subject: [PATCH 030/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 7a53296df..5dbc7edef 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -179,7 +179,7 @@ export class SimpleReactPyClient this.salt = ""; this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) - this.onMessage(messageTypes.isReady, (salt) => { this.isReady = true; this.salt = salt; }); + this.onMessage(messageTypes.isReady, (msg) => { this.isReady = true; this.salt = msg.salt; }); this.onMessage(messageTypes.clientState, () => { this.sendClientState() }); this.reconnect() diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 14cce54b3..3868c2bab 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -121,7 +121,7 @@ def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: for var in state_vars: state_key = getattr(var, "key", None) if state_key is not None: - serialized_value, signature = self._serialize(var.value) + serialized_value, signature = self._serialize(state_key, var.value) result[state_key] = (serialized_value, signature) return result From 12e2dbd1d1ab830426bd10c55607b2ccaf1384cc Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:16:23 +0000 Subject: [PATCH 031/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 3868c2bab..68967d877 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -126,12 +126,13 @@ def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: return result def _serialize(self, key: str, obj: object) -> tuple[str, str]: - try: - type_id = self._object_to_type_id[obj] - except KeyError as err: - raise ValueError( - f"Object {obj} was not white-listed for serialization" - ) from err + obj_type = type(obj) + for t in obj_type.__mro__: + type_id = self._object_to_type_id.get(t) + if type_id: + break + else: + raise ValueError(f"Object {obj} was not white-listed for serialization") result = self._serialize_object(obj) if len(result) > self._max_object_length: raise ValueError( From 4ddce1db86cbe8c3a3a0152b3655a55263d123d2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:21:22 +0000 Subject: [PATCH 032/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 68967d877..c8c82181a 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -139,7 +139,7 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str]: f"Serialized object {obj} is too long (length: {len(result)})" ) signature = self._sign_serialization(key, type_id, result) - return (base64.urlsafe_b64encode(result), signature) + return (base64.urlsafe_b64encode(result).encode("utf-8"), signature) def deserialize_client_state(self, state_vars: dict[str, tuple[str, str]]) -> None: return { From 39f9b468f5d6d1d70193984e56c383223dff5bb3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:22:36 +0000 Subject: [PATCH 033/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index c8c82181a..b90dd9b9a 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -139,7 +139,7 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str]: f"Serialized object {obj} is too long (length: {len(result)})" ) signature = self._sign_serialization(key, type_id, result) - return (base64.urlsafe_b64encode(result).encode("utf-8"), signature) + return (base64.urlsafe_b64encode(result).decode("utf-8"), signature) def deserialize_client_state(self, state_vars: dict[str, tuple[str, str]]) -> None: return { From 2106d523cd1f724ec1cccc1b1182bed7a54aae98 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:36:43 +0000 Subject: [PATCH 034/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index b90dd9b9a..e4ce84e9f 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -55,20 +55,12 @@ def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: str(id(obj)).encode("utf-8"): obj for obj in serializable_objects } - self._object_to_id[None] = b"0" - self._type_id_to_object[b"0"] = None - - self._object_to_id[str] = b"1" - self._type_id_to_object[b"1"] = str - - self._object_to_id[int] = b"2" - self._type_id_to_object[b"2"] = int - - self._object_to_id[float] = b"3" - self._type_id_to_object[b"3"] = float - - self._object_to_id[bool] = b"4" - self._type_id_to_object[b"4"] = bool + for idx, typ in ( + None, str, int, float, bool, list, tuple + ): + idx_as_bytes = str(idx).encode("utf-8") + self._object_to_id[typ] = idx_as_bytes + self._type_id_to_object[idx_as_bytes] = typ def _discover_otp_key(self) -> str: """ From 0234ae0801e24b4fc5831fc100e817a66d7e13d1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:44:43 +0000 Subject: [PATCH 035/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- src/py/reactpy/reactpy/core/state_recovery.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 5dbc7edef..0f12a3f47 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -61,7 +61,7 @@ export abstract class BaseReactPyClient implements ReactPyClient { abstract loadModule(moduleName: string): Promise; updateStateVars(givenStateVars: object): void { - Object.assign(this.stateVars, givenStateVars); + this.stateVars = Object.assign(this.stateVars, givenStateVars); logger.log(this.stateVars); } diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index e4ce84e9f..58487e9ce 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -55,9 +55,7 @@ def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: str(id(obj)).encode("utf-8"): obj for obj in serializable_objects } - for idx, typ in ( - None, str, int, float, bool, list, tuple - ): + for idx, typ in enumerate((None, str, int, float, bool, list, tuple)): idx_as_bytes = str(idx).encode("utf-8") self._object_to_id[typ] = idx_as_bytes self._type_id_to_object[idx_as_bytes] = typ From daaec4e217584eb4f0ab39f32e5d7d3835f74fbf Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:47:21 +0000 Subject: [PATCH 036/166] wip --- src/js/packages/@reactpy/client/src/components.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 266bf9d38..fd23d3a8a 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -29,13 +29,13 @@ export function Layout(props: { client: ReactPyClient }): JSX.Element { useEffect( () => - props.client.onMessage("layout-update", ({ path, model, stateVars }) => { + props.client.onMessage("layout-update", ({ path, model, state_vars }) => { if (path === "") { Object.assign(currentModel, model); } else { setJsonPointer(currentModel, path, model); } - props.client.updateStateVars(stateVars); + props.client.updateStateVars(state_vars); forceUpdate(); }), [currentModel, props.client], From c2b6c520416294b2baf5f4206f006bc260fcc4bf Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:51:48 +0000 Subject: [PATCH 037/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 58487e9ce..955e44ace 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -111,11 +111,10 @@ def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: for var in state_vars: state_key = getattr(var, "key", None) if state_key is not None: - serialized_value, signature = self._serialize(state_key, var.value) - result[state_key] = (serialized_value, signature) + result[state_key] = self._serialize(state_key, var.value) return result - def _serialize(self, key: str, obj: object) -> tuple[str, str]: + def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: obj_type = type(obj) for t in obj_type.__mro__: type_id = self._object_to_type_id.get(t) @@ -129,7 +128,11 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str]: f"Serialized object {obj} is too long (length: {len(result)})" ) signature = self._sign_serialization(key, type_id, result) - return (base64.urlsafe_b64encode(result).decode("utf-8"), signature) + return ( + type_id.decode("utf-8"), + base64.urlsafe_b64encode(result).decode("utf-8"), + signature, + ) def deserialize_client_state(self, state_vars: dict[str, tuple[str, str]]) -> None: return { From 7a3b27a03d8f42cd931f7ce5b09efad548aec992 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:57:51 +0000 Subject: [PATCH 038/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 955e44ace..cb135d931 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -48,14 +48,9 @@ def __init__( ) def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: - self._object_to_id = { - obj: str(id(obj)).encode("utf-8") for obj in serializable_objects - } - self._type_id_to_object = { - str(id(obj)).encode("utf-8"): obj for obj in serializable_objects - } - - for idx, typ in enumerate((None, str, int, float, bool, list, tuple)): + for idx, typ in enumerate( + (None, str, int, float, bool, list, tuple, *serializable_objects) + ): idx_as_bytes = str(idx).encode("utf-8") self._object_to_id[typ] = idx_as_bytes self._type_id_to_object[idx_as_bytes] = typ @@ -134,13 +129,17 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: signature, ) - def deserialize_client_state(self, state_vars: dict[str, tuple[str, str]]) -> None: + def deserialize_client_state( + self, state_vars: dict[str, tuple[str, str, str]] + ) -> None: return { - key: self._deserialize(key, data, signature) - for key, (data, signature) in state_vars.items() + key: self._deserialize(key, type_id.encode("utf-8"), data, signature) + for key, (type_id, data, signature) in state_vars.items() } - def _deserialize(self, key: str, type_id: int, data: bytes, signature: str) -> Any: + def _deserialize( + self, key: str, type_id: bytes, data: bytes, signature: str + ) -> Any: try: typ = self._type_id_to_object[type_id] except KeyError as err: From 5dd5caf6608d76a2bf0d2a39301e291e51c913c1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:09:05 +0000 Subject: [PATCH 039/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index cb135d931..a76ee833d 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -27,6 +27,7 @@ def __init__( max_objects: int = 1024, max_object_length: int = 512, default_serializer: Callable[[Any], bytes] | None = None, + deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: self._pepper = pepper self._max_objects = max_objects @@ -48,11 +49,13 @@ def __init__( ) def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: + self._object_to_type_id = {} + self._type_id_to_object = {} for idx, typ in enumerate( (None, str, int, float, bool, list, tuple, *serializable_objects) ): idx_as_bytes = str(idx).encode("utf-8") - self._object_to_id[typ] = idx_as_bytes + self._object_to_type_id[typ] = idx_as_bytes self._type_id_to_object[idx_as_bytes] = typ def _discover_otp_key(self) -> str: @@ -78,6 +81,7 @@ def create_serializer( type_id_to_object=self._type_id_to_object, max_object_length=self._max_object_length, default_serializer=self._default_serializer, + deserializer_map=self._deserializer_map, ) @@ -92,6 +96,7 @@ def __init__( type_id_to_object: dict[bytes, Any], max_object_length: int, default_serializer: Callable[[Any], bytes] | None = None, + deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: self._otp_code = otp_code.encode("utf-8") self._pepper = pepper.encode("utf-8") @@ -100,6 +105,7 @@ def __init__( self._type_id_to_object = type_id_to_object self._max_object_length = max_object_length self._default_serializer = default_serializer + self._deserializer_map = deserializer_map or {} def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: result = {} @@ -168,6 +174,9 @@ def _deserialize_object(self, typ: Any, data: bytes) -> Any: if typ is None: return None result = orjson.loads(data) + custom_deserializer = self._deserializer_map.get(typ) + if custom_deserializer: + return custom_deserializer(result) if isinstance(result, str): return typ(result) if isinstance(result, dict): From b028ec6d8b7b029e06e5ba947b9320ae13bc781c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:13:20 +0000 Subject: [PATCH 040/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index a76ee833d..d21a97133 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -37,6 +37,7 @@ def __init__( ) self._totp = pyotp.TOTP(self._otp_key, interval=otp_interval) self._default_serializer = default_serializer + self._deserializer_map = deserializer_map or {} self._map_objects_to_ids( [ @@ -77,7 +78,7 @@ def create_serializer( otp_code=self._totp.at(target_time or time.time()), pepper=self._pepper, salt=salt, - object_to_type_id=self._object_to_id, + object_to_type_id=self._object_to_type_id, type_id_to_object=self._type_id_to_object, max_object_length=self._max_object_length, default_serializer=self._default_serializer, From ea3c1e5ea5c970fe90977cad9ef2f9801cbfe1d4 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:25:17 +0000 Subject: [PATCH 041/166] wip --- src/py/reactpy/reactpy/core/serve.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index c1fac8a97..d45ce259c 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -14,7 +14,7 @@ from reactpy.backend.types import Connection from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core.layout import Layout -from reactpy.core.state_recovery import StateRecoveryManager +from reactpy.core.state_recovery import StateRecoveryFailureError, StateRecoveryManager from reactpy.core.types import ( ClientStateMessage, IsReadyMessage, @@ -175,12 +175,15 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> None: ) return state_vars = client_state_msg["value"] - serializer = self._state_recovery_manager.create_serializer( - client_state_msg["salt"] - ) - client_state = serializer.deserialize_client_state(state_vars) - layout.reconnecting = True - layout.client_state = client_state + try: + serializer = self._state_recovery_manager.create_serializer( + client_state_msg["salt"] + ) + client_state = serializer.deserialize_client_state(state_vars) + layout.reconnecting = True + layout.client_state = client_state + except StateRecoveryFailureError: + logger.warning("State recovery failed") layout.start_rendering() await layout.render() layout.reconnecting = False From 00ee75a745cd0d0e38fc7ad8ad3752e03980901e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:36:39 +0000 Subject: [PATCH 042/166] Reuse previous salt --- src/py/reactpy/reactpy/core/serve.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index d45ce259c..2be515e7b 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -1,9 +1,9 @@ from __future__ import annotations -from collections.abc import Awaitable -from logging import getLogger import random import string +from collections.abc import Awaitable +from logging import getLogger from typing import Callable from warnings import warn @@ -117,7 +117,7 @@ def __init__( self._send = send self._recv = recv self._state_recovery_manager = state_recovery_manager - self._salt = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + self._salt: str | None = None async def handle_connection( self, connection: Connection, constructor: RootComponentConstructor @@ -144,6 +144,7 @@ async def handle_connection( async def _handshake(self, layout: Layout) -> None: await self._send(ReconnectingCheckMessage(type="reconnecting-check")) result = await self._recv() + self._salt = "".join(random.choices(string.ascii_letters + string.digits, k=8)) if result["type"] == "reconnecting-check": if result["value"] == "yes": if self._state_recovery_manager is None: @@ -153,7 +154,7 @@ async def _handshake(self, layout: Layout) -> None: layout.start_rendering() else: logger.info("Handshake: Doing state rebuild for reconnection") - await self._do_state_rebuild_for_reconnection(layout) + self._salt = await self._do_state_rebuild_for_reconnection(layout) else: logger.info("Handshake: new connection") layout.start_rendering() @@ -166,7 +167,8 @@ async def _handshake(self, layout: Layout) -> None: async def _indicate_ready(self) -> None: await self._send(IsReadyMessage(type="is-ready", salt=self._salt)) - async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> None: + async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: + salt = self._salt await self._send(ClientStateMessage(type="client-state")) client_state_msg = await self._recv() if client_state_msg["type"] != "client-state": @@ -184,7 +186,10 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> None: layout.client_state = client_state except StateRecoveryFailureError: logger.warning("State recovery failed") + else: + salt = client_state_msg["salt"] layout.start_rendering() await layout.render() layout.reconnecting = False layout.client_state = {} + return salt From 6ea67ea83f8f4b5464d0798b1899189ddcf02524 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:40:55 +0000 Subject: [PATCH 043/166] wip --- src/py/reactpy/reactpy/core/layout.py | 6 ++++-- src/py/reactpy/reactpy/core/serve.py | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 81a500587..a2a1e4ffc 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -74,7 +74,6 @@ class Layout: def __init__( self, root: ComponentType, - state_recovery_serializer: StateRecoverySerializer | None = None, ) -> None: super().__init__() if not isinstance(root, ComponentType): @@ -82,9 +81,12 @@ def __init__( raise TypeError(msg) self.root = root self.reconnecting = False - self._state_recovery_serializer = state_recovery_serializer + self._state_recovery_serializer = None self.client_state = {} + def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: + self._state_recovery_serializer = serializer + async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 2be515e7b..4424cbda3 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -127,14 +127,15 @@ async def handle_connection( constructor(), value=connection, ), - ( - self._state_recovery_manager.create_serializer(self._salt) - if self._state_recovery_manager - else None - ), ) async with layout: await self._handshake(layout) + # salt may be set to client's old salt during handshake + assert self._salt is not None + if self._state_recovery_manager: + layout.set_recovery_serializer( + self._state_recovery_manager.create_serializer(self._salt) + ) await serve_layout( layout, self._send, From 9dd6fc0f0cbefbf89d3dbbb5ab558827a7c1b3a2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:50:30 +0000 Subject: [PATCH 044/166] Add recovery to use_ref and support UUID --- src/py/reactpy/reactpy/core/hooks.py | 11 ++++++++++- src/py/reactpy/reactpy/core/serve.py | 1 - src/py/reactpy/reactpy/core/state_recovery.py | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 120eec5fc..a40e8d2e5 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -477,7 +477,7 @@ def empty(self) -> bool: return False -def use_ref(initial_value: _Type) -> Ref[_Type]: +def use_ref(initial_value: _Type, server_only: bool = False) -> Ref[_Type]: """See the full :ref:`Use State` docs for details Parameters: @@ -486,6 +486,15 @@ def use_ref(initial_value: _Type) -> Ref[_Type]: Returns: A :class:`Ref` object. """ + if server_only: + key = None + else: + caller_info = get_caller_info() + key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() + hook = current_hook() + if hook.reconnecting: + # TODO: if key is missing, maybe raise exception and abort recovery? + initial_value = hook.client_state.get(key, initial_value) return _use_const(lambda: Ref(initial_value)) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 4424cbda3..5cf8e8117 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -131,7 +131,6 @@ async def handle_connection( async with layout: await self._handshake(layout) # salt may be set to client's old salt during handshake - assert self._salt is not None if self._state_recovery_manager: layout.set_recovery_serializer( self._state_recovery_manager.create_serializer(self._salt) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index d21a97133..23b90c754 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -6,6 +6,7 @@ from decimal import Decimal from pathlib import Path from typing import Any, Callable +from uuid import UUID import orjson import pyotp @@ -53,7 +54,7 @@ def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: self._object_to_type_id = {} self._type_id_to_object = {} for idx, typ in enumerate( - (None, str, int, float, bool, list, tuple, *serializable_objects) + (None, str, int, float, bool, list, tuple, UUID, *serializable_objects) ): idx_as_bytes = str(idx).encode("utf-8") self._object_to_type_id[typ] = idx_as_bytes From b647c5578b13d9194469bae9c0013e70d494a6d6 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:01:49 +0000 Subject: [PATCH 045/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 6 ++++-- src/py/reactpy/reactpy/utils.py | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a40e8d2e5..380a3967a 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -57,7 +57,9 @@ def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ... def use_state(initial_value: _Type) -> State[_Type]: ... -def use_state(initial_value: _Type | Callable[[], _Type], *, server_only: bool = False) -> State[_Type]: +def use_state( + initial_value: _Type | Callable[[], _Type], *, server_only: bool = False +) -> State[_Type]: """See the full :ref:`Use State` docs for details Parameters: @@ -495,7 +497,7 @@ def use_ref(initial_value: _Type, server_only: bool = False) -> Ref[_Type]: if hook.reconnecting: # TODO: if key is missing, maybe raise exception and abort recovery? initial_value = hook.client_state.get(key, initial_value) - return _use_const(lambda: Ref(initial_value)) + return _use_const(lambda: Ref(initial_value, key)) def _use_const(function: Callable[[], _Type]) -> _Type: diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index 5624846a4..aae683609 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -27,12 +27,15 @@ class Ref(Generic[_RefValue]): You can compare the contents for two ``Ref`` objects using the ``==`` operator. """ - __slots__ = ("current",) + __slots__ = ("current", "key") - def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None: + def __init__( + self, initial_value: _RefValue = _UNDEFINED, key: str | None = None + ) -> None: if initial_value is not _UNDEFINED: self.current = initial_value """The present value""" + self.key = key def set_current(self, new: _RefValue) -> _RefValue: """Set the current value and return what is now the old value From 00228d08bf4e8ece5954050f3a375a042656f801 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:03:47 +0000 Subject: [PATCH 046/166] wip --- src/py/reactpy/reactpy/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index aae683609..26c10b3cb 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -37,6 +37,10 @@ def __init__( """The present value""" self.key = key + @property + def value(self) -> Any: + return self.current + def set_current(self, new: _RefValue) -> _RefValue: """Set the current value and return what is now the old value From c204229a3ba1beb0ff9b149854f5664cda281fec Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:06:51 +0000 Subject: [PATCH 047/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 23b90c754..10d71f17d 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -118,6 +118,8 @@ def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: return result def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: + if obj is None: + return b"0", b"", b"" obj_type = type(obj) for t in obj_type.__mro__: type_id = self._object_to_type_id.get(t) @@ -148,6 +150,8 @@ def deserialize_client_state( def _deserialize( self, key: str, type_id: bytes, data: bytes, signature: str ) -> Any: + if type_id == b"0": + return None try: typ = self._type_id_to_object[type_id] except KeyError as err: From 941d37758955131f74e6332ce4711a51548058e1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:11:14 +0000 Subject: [PATCH 048/166] wip --- src/py/reactpy/reactpy/backend/sanic.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 32e2882a6..63aab3ecd 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -7,6 +7,7 @@ from typing import Any from urllib import parse as urllib_parse from uuid import uuid4 +import orjson from sanic import Blueprint, Sanic, request, response from sanic.config import Config @@ -219,13 +220,13 @@ def _make_send_recv_callbacks( socket: WebSocketConnection, ) -> tuple[SendCoroutine, RecvCoroutine]: async def sock_send(value: Any) -> None: - await socket.send(json.dumps(value)) + await socket.send(orjson.dumps(value)) async def sock_recv() -> Any: data = await socket.recv() if data is None: raise Stop() - return json.loads(data) + return orjson.loads(data) return sock_send, sock_recv From 2f3da7e614c110a5b2da85b4ee4d337cd0f192b4 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:17:53 +0000 Subject: [PATCH 049/166] wip --- src/py/reactpy/reactpy/backend/sanic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 63aab3ecd..0455923f1 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -220,13 +220,13 @@ def _make_send_recv_callbacks( socket: WebSocketConnection, ) -> tuple[SendCoroutine, RecvCoroutine]: async def sock_send(value: Any) -> None: - await socket.send(orjson.dumps(value)) + await socket.send(orjson.dumps(value).decode("utf-8")) async def sock_recv() -> Any: data = await socket.recv() if data is None: raise Stop() - return orjson.loads(data) + return orjson.loads(data).encode("utf-8") return sock_send, sock_recv From 4340dcf097c6f9845cfc5ecc57964b2545df4756 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:18:46 +0000 Subject: [PATCH 050/166] wip --- src/py/reactpy/reactpy/backend/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 0455923f1..bad90b072 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -226,7 +226,7 @@ async def sock_recv() -> Any: data = await socket.recv() if data is None: raise Stop() - return orjson.loads(data).encode("utf-8") + return orjson.loads(data) return sock_send, sock_recv From e153a89e0740b71f0560728a3a21c68cebd1d03a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 06:21:29 +0000 Subject: [PATCH 051/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 10d71f17d..1b020bb0c 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -119,7 +119,7 @@ def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: if obj is None: - return b"0", b"", b"" + return "0", "", "" obj_type = type(obj) for t in obj_type.__mro__: type_id = self._object_to_type_id.get(t) From 18e82ebc462bf8cacbae7494183ee8dfb2a964c8 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 07:23:26 +0000 Subject: [PATCH 052/166] fixes --- src/py/reactpy/reactpy/core/hooks.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 380a3967a..23c194d3a 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -87,8 +87,9 @@ def use_state( def get_caller_info(): # Get the current stack frame and then the frame above it caller_frame = sys._getframe(2) + render_frame = sys._getframe(5) # Extract the relevant information: file path and line number - return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno}" + return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {render_frame.f_locals['new_state'].patch_path} class _CurrentState(Generic[_Type]): @@ -165,6 +166,8 @@ def use_effect( return dependencies = None else: + if dependencies is ReconnectingOnly: + return dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) @@ -479,7 +482,7 @@ def empty(self) -> bool: return False -def use_ref(initial_value: _Type, server_only: bool = False) -> Ref[_Type]: +def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: """See the full :ref:`Use State` docs for details Parameters: From 70691828bebaa3bbb41920b5999516d298d9e483 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:45:26 +0000 Subject: [PATCH 053/166] wip --- .../reactpy/reactpy/core/_life_cycle_hook.py | 11 +++++++--- src/py/reactpy/reactpy/core/hooks.py | 21 +++++++++++++------ src/py/reactpy/reactpy/core/layout.py | 2 +- src/py/reactpy/reactpy/core/serve.py | 4 +++- src/py/reactpy/reactpy/core/state_recovery.py | 10 ++++----- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index bdfee46fd..48380c2f3 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -7,6 +7,7 @@ from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal +from reactpy.core.hooks import _CurrentState from reactpy.core.types import ComponentType, Context, ContextProviderType T = TypeVar("T") @@ -118,7 +119,8 @@ async def my_effect(stop_event): "_state", "component", "reconnecting", - "client_state" + "client_state", + "_updated_states", ) component: ComponentType @@ -127,7 +129,7 @@ def __init__( self, schedule_render: Callable[[], None], reconnecting: bool, - client_state: dict[str, Any] + client_state: dict[str, Any], ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -141,8 +143,11 @@ def __init__( self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting self.client_state = client_state or {} + self._updated_states = {} - def schedule_render(self) -> None: + def schedule_render(self, updated_state: _CurrentState) -> None: + if updated_state.key: + self._updated_states[updated_state.key] = updated_state.value if self._scheduled_render: return None try: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 23c194d3a..714fcbb02 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -22,6 +22,7 @@ from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core._life_cycle_hook import current_hook +from reactpy.core.state_recovery import StateRecoveryFailureError from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref @@ -78,8 +79,12 @@ def use_state( key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() hook = current_hook() if hook.reconnecting: - # TODO: if key is missing, maybe raise exception and abort recovery? - initial_value = hook.client_state.get(key, initial_value) + try: + initial_value = hook.client_state[key] + except KeyError as err: + raise StateRecoveryFailureError( + f"Missing expected key {key} on client" + ) from err current_state = _use_const(lambda: _CurrentState(key, initial_value)) return State(current_state.value, current_state.dispatch) @@ -89,7 +94,7 @@ def get_caller_info(): caller_frame = sys._getframe(2) render_frame = sys._getframe(5) # Extract the relevant information: file path and line number - return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {render_frame.f_locals['new_state'].patch_path} + return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {render_frame.f_locals['new_state'].patch_path}" class _CurrentState(Generic[_Type]): @@ -115,7 +120,7 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: next_value = new if not strictly_equal(next_value, self.value): self.value = next_value - hook.schedule_render() + hook.schedule_render(self) self.dispatch = dispatch @@ -498,8 +503,12 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() hook = current_hook() if hook.reconnecting: - # TODO: if key is missing, maybe raise exception and abort recovery? - initial_value = hook.client_state.get(key, initial_value) + try: + initial_value = hook.client_state[key] + except KeyError as err: + raise StateRecoveryFailureError( + f"Missing expected key {key} on client" + ) from err return _use_const(lambda: Ref(initial_value, key)) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index a2a1e4ffc..db2e531ca 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -192,7 +192,7 @@ async def _create_layout_update( model=new_state.model.current, state_vars=( self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._state + new_state.life_cycle_state.hook._updated_states ) if self._state_recovery_serializer else {} diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 5cf8e8117..55d8e18d0 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -185,7 +185,9 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: layout.reconnecting = True layout.client_state = client_state except StateRecoveryFailureError: - logger.warning("State recovery failed") + logger.exception("State recovery failed") + layout.reconnecting = False + layout.client_state = {} else: salt = client_state_msg["salt"] layout.start_rendering() diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 1b020bb0c..b3a1833d3 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -109,12 +109,12 @@ def __init__( self._default_serializer = default_serializer self._deserializer_map = deserializer_map or {} - def serialize_state_vars(self, state_vars: Iterable[Any]) -> tuple[str, str]: + def serialize_state_vars( + self, state_vars: dict[str, Any] + ) -> dict[str, tuple[str, str, str]]: result = {} - for var in state_vars: - state_key = getattr(var, "key", None) - if state_key is not None: - result[state_key] = self._serialize(state_key, var.value) + for key, value in state_vars.items(): + result[key] = self._serialize(key, value) return result def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: From b50f7efe2863b0f049d472a1e85d16f814c81379 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:51:37 +0000 Subject: [PATCH 054/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 714fcbb02..d4ce4a737 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -112,6 +112,7 @@ def __init__( self.value = initial_value hook = current_hook() + hook.schedule_render(self) def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: if callable(new): From 2c4408dde08da7e46548d75a4ce650b6ec72f0bc Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:52:36 +0000 Subject: [PATCH 055/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 48380c2f3..e6bc7ac89 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -2,14 +2,16 @@ import logging from asyncio import Event, Task, create_task, gather -from typing import Any, Callable, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal -from reactpy.core.hooks import _CurrentState from reactpy.core.types import ComponentType, Context, ContextProviderType +if TYPE_CHECKING: + from reactpy.core.hooks import _CurrentState + T = TypeVar("T") From b082896f0ee5f912d39f83ee5972ec8cfff6f9d3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 19:08:05 +0000 Subject: [PATCH 056/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 5 ++++- src/py/reactpy/reactpy/core/hooks.py | 5 +++-- src/py/reactpy/reactpy/utils.py | 9 ++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index e6bc7ac89..d488813be 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -8,6 +8,7 @@ from reactpy.core._thread_local import ThreadLocal from reactpy.core.types import ComponentType, Context, ContextProviderType +from reactpy.utils import Ref if TYPE_CHECKING: from reactpy.core.hooks import _CurrentState @@ -147,9 +148,11 @@ def __init__( self.client_state = client_state or {} self._updated_states = {} - def schedule_render(self, updated_state: _CurrentState) -> None: + def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if updated_state.key: self._updated_states[updated_state.key] = updated_state.value + + def schedule_render(self) -> None: if self._scheduled_render: return None try: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index d4ce4a737..a659e791d 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -112,7 +112,7 @@ def __init__( self.value = initial_value hook = current_hook() - hook.schedule_render(self) + hook.add_state_update(self) def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: if callable(new): @@ -121,7 +121,8 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: next_value = new if not strictly_equal(next_value, self.value): self.value = next_value - hook.schedule_render(self) + hook.add_state_update(self) + hook.schedule_render() self.dispatch = dispatch diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index 26c10b3cb..6bd1cc680 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -8,6 +8,7 @@ from lxml import etree from lxml.html import fromstring, tostring +from reactpy.core._life_cycle_hook import current_hook from reactpy.core.types import VdomDict from reactpy.core.vdom import vdom @@ -36,9 +37,12 @@ def __init__( self.current = initial_value """The present value""" self.key = key + if key: + hook = current_hook() + hook.add_state_update(self) @property - def value(self) -> Any: + def value(self) -> _RefValue: return self.current def set_current(self, new: _RefValue) -> _RefValue: @@ -48,6 +52,9 @@ def set_current(self, new: _RefValue) -> _RefValue: """ old = self.current self.current = new + if self.key: + hook = current_hook() + hook.add_state_update(self) return old def __eq__(self, other: Any) -> bool: From 88dae2449941415e41ef2bb909d0871bf2bc5ea2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 19:09:17 +0000 Subject: [PATCH 057/166] wip --- src/py/reactpy/reactpy/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index 6bd1cc680..4f28f8da2 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -8,7 +8,6 @@ from lxml import etree from lxml.html import fromstring, tostring -from reactpy.core._life_cycle_hook import current_hook from reactpy.core.types import VdomDict from reactpy.core.vdom import vdom @@ -33,6 +32,8 @@ class Ref(Generic[_RefValue]): def __init__( self, initial_value: _RefValue = _UNDEFINED, key: str | None = None ) -> None: + from reactpy.core._life_cycle_hook import current_hook + if initial_value is not _UNDEFINED: self.current = initial_value """The present value""" @@ -50,6 +51,8 @@ def set_current(self, new: _RefValue) -> _RefValue: This is nice to use in ``lambda`` functions. """ + from reactpy.core._life_cycle_hook import current_hook + old = self.current self.current = new if self.key: From 8e1186595538b100d3815ebd2f2fdd9f262f9dc5 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 19:23:04 +0000 Subject: [PATCH 058/166] wip --- src/py/reactpy/reactpy/core/layout.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index db2e531ca..3dde9081f 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -540,7 +540,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state + component, schedule_render, reconnecting, client_state, {} ), ) @@ -563,7 +563,11 @@ def _make_component_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state + component, + schedule_render, + reconnecting, + client_state, + parent.life_cycle_state.hook._updated_states, ), ) @@ -608,7 +612,11 @@ def _update_component_model_state( _update_life_cycle_state(old_model_state.life_cycle_state, new_component) if old_model_state.is_component_state else _make_life_cycle_state( - new_component, schedule_render, reconnecting, client_state + new_component, + schedule_render, + reconnecting, + client_state, + new_parent.life_cycle_state.hook._updated_states, ) ), ) @@ -725,6 +733,7 @@ def _make_life_cycle_state( schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any], + updated_states: dict[str, Any], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( @@ -733,6 +742,7 @@ def _make_life_cycle_state( lambda: schedule_render(life_cycle_state_id), reconnecting=reconnecting, client_state=client_state, + updated_states=updated_states, ), component, ) From f0cc8843763ed6609fa366120d1ec8469fd8d4d3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 22:16:55 +0000 Subject: [PATCH 059/166] wip --- .../reactpy/reactpy/core/_life_cycle_hook.py | 5 +++-- src/py/reactpy/reactpy/core/hooks.py | 15 +++++++------ src/py/reactpy/reactpy/core/layout.py | 22 ++++++++++++------- src/py/reactpy/reactpy/core/serve.py | 6 ++--- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index d488813be..01cfe021e 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -131,8 +131,9 @@ async def my_effect(stop_event): def __init__( self, schedule_render: Callable[[], None], - reconnecting: bool, + reconnecting: Ref, client_state: dict[str, Any], + updated_states: dict[str, Any], ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -146,7 +147,7 @@ def __init__( self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting self.client_state = client_state or {} - self._updated_states = {} + self._updated_states = updated_states def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if updated_state.key: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a659e791d..e9d87c231 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -75,10 +75,10 @@ def use_state( if server_only: key = None else: + hook = current_hook() caller_info = get_caller_info() key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() - hook = current_hook() - if hook.reconnecting: + if hook.reconnecting.current: try: initial_value = hook.client_state[key] except KeyError as err: @@ -92,9 +92,10 @@ def use_state( def get_caller_info(): # Get the current stack frame and then the frame above it caller_frame = sys._getframe(2) - render_frame = sys._getframe(5) + render_frame = sys._getframe(4) + patch_path = render_frame.f_locals["patch_path_for_state"] # Extract the relevant information: file path and line number - return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {render_frame.f_locals['new_state'].patch_path}" + return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" class _CurrentState(Generic[_Type]): @@ -168,7 +169,7 @@ def use_effect( If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - if hook.reconnecting: + if hook.reconnecting.current: if dependencies is not ReconnectingOnly: return dependencies = None @@ -501,10 +502,10 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: if server_only: key = None else: + hook = current_hook() caller_info = get_caller_info() key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() - hook = current_hook() - if hook.reconnecting: + if hook.reconnecting.current: try: initial_value = hook.client_state[key] except KeyError as err: diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 3dde9081f..aceabc014 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -80,7 +80,7 @@ def __init__( msg = f"Expected a ComponentType, not {type(root)!r}." raise TypeError(msg) self.root = root - self.reconnecting = False + self.reconnecting = Ref(False) self._state_recovery_serializer = None self.client_state = {} @@ -214,6 +214,7 @@ async def _render_component( await life_cycle_hook.affect_component_will_render(component) exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render) try: + patch_path_for_state = new_state.patch_path # type: ignore # noqa raw_model = component.render() # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this @@ -501,8 +502,11 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: if model_state.is_component_state: life_cycle_state = model_state.life_cycle_state - del self._model_states_by_life_cycle_state_id[life_cycle_state.id] - await life_cycle_state.hook.affect_component_will_unmount() + try: + del self._model_states_by_life_cycle_state_id[life_cycle_state.id] + await life_cycle_state.hook.affect_component_will_unmount() + except KeyError: + pass # sideeffect of reusing model states to_unmount.extend(model_state.children_by_key.values()) @@ -554,6 +558,7 @@ def _make_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: + updated_states = parent.life_cycle_state.hook._updated_states return _ModelState( parent=parent, index=index, @@ -567,7 +572,7 @@ def _make_component_model_state( schedule_render, reconnecting, client_state, - parent.life_cycle_state.hook._updated_states, + updated_states, ), ) @@ -635,6 +640,7 @@ def _make_element_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, + life_cycle_state=parent.life_cycle_state, ) @@ -651,6 +657,7 @@ def _update_element_model_state( patch_path=old_model_state.patch_path, children_by_key={}, targets_by_event={}, + life_cycle_state=new_parent.life_cycle_state, ) @@ -679,7 +686,7 @@ def __init__( patch_path: str, children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], - life_cycle_state: _LifeCycleState | None = None, + life_cycle_state: _LifeCycleState, ): self.index = index """The index of the element amongst its siblings""" @@ -706,9 +713,8 @@ def __init__( self._parent_ref = weakref(parent) """The parent model state""" - if life_cycle_state is not None: - self.life_cycle_state = life_cycle_state - """The state for the element's component (if it has one)""" + self.life_cycle_state = life_cycle_state + """The state for the element's component (if it has one)""" @property def is_component_state(self) -> bool: diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 55d8e18d0..441b276f0 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -182,16 +182,16 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: client_state_msg["salt"] ) client_state = serializer.deserialize_client_state(state_vars) - layout.reconnecting = True + layout.reconnecting.set_current(True) layout.client_state = client_state except StateRecoveryFailureError: logger.exception("State recovery failed") - layout.reconnecting = False + layout.reconnecting.set_current(False) layout.client_state = {} else: salt = client_state_msg["salt"] layout.start_rendering() await layout.render() - layout.reconnecting = False + layout.reconnecting.set_current(False) layout.client_state = {} return salt From 7a3e8dd7563327f5fd0037b4700928345c4191cd Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 22:48:12 +0000 Subject: [PATCH 060/166] wip --- src/py/reactpy/reactpy/core/layout.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index aceabc014..a16118e7f 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -558,7 +558,9 @@ def _make_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: - updated_states = parent.life_cycle_state.hook._updated_states + updated_states = ( + parent.life_cycle_state or parent.parent_life_cycle_state + ).hook._updated_states return _ModelState( parent=parent, index=index, @@ -621,7 +623,9 @@ def _update_component_model_state( schedule_render, reconnecting, client_state, - new_parent.life_cycle_state.hook._updated_states, + ( + new_parent.life_cycle_state or new_parent.parent_life_cycle_state + ).hook._updated_states, ) ), ) @@ -640,7 +644,9 @@ def _make_element_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, - life_cycle_state=parent.life_cycle_state, + parent_life_cycle_state=( + parent.life_cycle_state or parent.parent_life_cycle_state + ), ) @@ -657,7 +663,9 @@ def _update_element_model_state( patch_path=old_model_state.patch_path, children_by_key={}, targets_by_event={}, - life_cycle_state=new_parent.life_cycle_state, + parent_life_cycle_state=( + new_parent.life_cycle_state or new_parent.parent_life_cycle_state + ), ) @@ -672,6 +680,7 @@ class _ModelState: "index", "key", "life_cycle_state", + "parent_life_cycle_state", "model", "patch_path", "targets_by_event", @@ -686,7 +695,8 @@ def __init__( patch_path: str, children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], - life_cycle_state: _LifeCycleState, + life_cycle_state: _LifeCycleState | None = None, + parent_life_cycle_state: _LifeCycleState | None = None, ): self.index = index """The index of the element amongst its siblings""" @@ -716,9 +726,11 @@ def __init__( self.life_cycle_state = life_cycle_state """The state for the element's component (if it has one)""" + self.parent_life_cycle_state = parent_life_cycle_state + @property def is_component_state(self) -> bool: - return hasattr(self, "life_cycle_state") + return self.life_cycle_state is not None @property def parent(self) -> _ModelState: From 1da2dd072da8c5d60e487045617fe47ed6934e08 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 23:06:05 +0000 Subject: [PATCH 061/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 01cfe021e..b804f27e0 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -140,7 +140,7 @@ def __init__( self._scheduled_render = False self._rendered_atleast_once = False self._current_state_index = 0 - self._state: tuple[Any, ...] = () + self._state: list = [] self._effect_funcs: list[EffectFunc] = [] self._effect_tasks: list[Task[None]] = [] self._effect_stops: list[Event] = [] @@ -174,11 +174,10 @@ def use_state(self, function: Callable[[], T]) -> T: if not self._rendered_atleast_once: # since we're not initialized yet we're just appending state result = function() - self._state += (result,) + self._state.append(result) else: # once finalized we iterate over each succesively used piece of state - result = self._state[self._current_state_index] - self._current_state_index += 1 + result = self._state[-1] return result def add_effect(self, effect_func: EffectFunc) -> None: @@ -220,7 +219,7 @@ async def affect_component_did_render(self) -> None: """The component completed a render""" self.unset_current() self._rendered_atleast_once = True - self._current_state_index = 0 + self._state = [self._state[-1]] if self._state else [] self._render_access.release() del self.component From 966df84522609603e17db595f0e34acc1c4f0648 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 23:45:11 +0000 Subject: [PATCH 062/166] working better --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 5 +++-- src/py/reactpy/reactpy/core/hooks.py | 4 ++-- src/py/reactpy/reactpy/core/state_recovery.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index b804f27e0..3df66fa38 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -177,7 +177,8 @@ def use_state(self, function: Callable[[], T]) -> T: self._state.append(result) else: # once finalized we iterate over each succesively used piece of state - result = self._state[-1] + result = self._state[self._current_state_index] + self._current_state_index += 1 return result def add_effect(self, effect_func: EffectFunc) -> None: @@ -219,7 +220,7 @@ async def affect_component_did_render(self) -> None: """The component completed a render""" self.unset_current() self._rendered_atleast_once = True - self._state = [self._state[-1]] if self._state else [] + self._current_state_index = 0 self._render_access.release() del self.component diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index e9d87c231..e4318c3e9 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -168,6 +168,8 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ + memoize = use_memo(dependencies=dependencies) + last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) hook = current_hook() if hook.reconnecting.current: if dependencies is not ReconnectingOnly: @@ -177,8 +179,6 @@ def use_effect( if dependencies is ReconnectingOnly: return dependencies = _try_to_infer_closure_values(function, dependencies) - memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) def add_effect(function: _EffectApplyFunc) -> None: if not asyncio.iscoroutinefunction(function): diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index b3a1833d3..9a095dee6 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -25,8 +25,8 @@ def __init__( pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), - max_objects: int = 1024, - max_object_length: int = 512, + max_objects: int = 256, + max_object_length: int = 2048, default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: From 0a65c335d98784fbc1c036329ab00fce493da663 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 23:52:45 +0000 Subject: [PATCH 063/166] Bump up objects and length limits --- src/py/reactpy/reactpy/core/serve.py | 1 + src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 441b276f0..aebe2f13e 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -155,6 +155,7 @@ async def _handshake(self, layout: Layout) -> None: else: logger.info("Handshake: Doing state rebuild for reconnection") self._salt = await self._do_state_rebuild_for_reconnection(layout) + logger.info("Handshake: Completed doing state rebuild") else: logger.info("Handshake: new connection") layout.start_rendering() diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 9a095dee6..a9aedf609 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -26,7 +26,7 @@ def __init__( otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), max_objects: int = 256, - max_object_length: int = 2048, + max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: From 0d46a04d50cc92f6298acce79eddecf88b491d6c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Fri, 1 Mar 2024 23:54:07 +0000 Subject: [PATCH 064/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index e4318c3e9..d7bd42fae 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -43,8 +43,8 @@ logger = getLogger(__name__) -class ReconnectingOnly: - """Class for when an effect should only be applied on reconnection to the server""" +# Faux object used for dependency of reconnecting +ReconnectingOnly = ["reconnecting-faux-class"] _Type = TypeVar("_Type") From ea929e98b506d4096ecb88d0133cad38986d84c8 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:04:29 +0000 Subject: [PATCH 065/166] adjust background intervals --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 0f12a3f47..149f177db 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -154,6 +154,7 @@ export class SimpleReactPyClient private forceRerender: boolean; private messageQueue: any[] = []; private socketLoopIntervalId?: number | null; + private idleCheckIntervalId?: number | null; private sleeping: boolean; private isReconnecting: boolean; private isReady: boolean; @@ -207,7 +208,6 @@ export class SimpleReactPyClient const message = this.messageQueue.shift(); // Remove the first message from the queue this.transmitMessage(message); } - this.idleTimeoutCheck(); } transmitMessage(message: any): void { @@ -218,6 +218,8 @@ export class SimpleReactPyClient } idleTimeoutCheck(): void { + if (!this.socket) + return; if (Date.now() - this.lastMessageTime > this.idleDisconnectTimeMillis) { if (this.socket.current && this.socket.current.readyState === WebSocket.OPEN) { logger.warn("Closing socket connection due to idle activity"); @@ -237,6 +239,8 @@ export class SimpleReactPyClient this.isReady = false; if (this.socketLoopIntervalId) clearInterval(this.socketLoopIntervalId); + if (this.idleCheckIntervalId) + clearInterval(this.idleCheckIntervalId); if (!this.sleeping) { this.reconnect(onOpen); } @@ -244,7 +248,8 @@ export class SimpleReactPyClient onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, ...this.reconnectOptions, }); - this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, 50); + this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, 30); + this.idleCheckIntervalId = window.setInterval(() => { this.idleTimeoutCheck() }, 10000); } ensureConnected(): void { From 23661b30201d66549be6ea966337a68626259255 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:11:16 +0000 Subject: [PATCH 066/166] move ReconnectingOnly to export --- src/py/reactpy/reactpy/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 49e357441..8b5bf22ef 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -5,6 +5,7 @@ from reactpy.core.component import component from reactpy.core.events import event from reactpy.core.hooks import ( + ReconnectingOnly, create_context, use_callback, use_context, @@ -33,6 +34,7 @@ "html", "Layout", "logging", + "ReconnectingOnly", "Ref", "run", "sample", From f00b49c3c7e444bb925edfc755a4962e55b4b496 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:16:13 +0000 Subject: [PATCH 067/166] support nested use_state in components --- src/py/reactpy/reactpy/core/hooks.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index d7bd42fae..97c56cbb8 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -92,8 +92,11 @@ def use_state( def get_caller_info(): # Get the current stack frame and then the frame above it caller_frame = sys._getframe(2) - render_frame = sys._getframe(4) - patch_path = render_frame.f_locals["patch_path_for_state"] + for i in range(50): + render_frame = sys._getframe(4 + i) + 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 return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" From 90015c9fcae477b43f36b0df800a5bdc5958f746 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:40:07 +0000 Subject: [PATCH 068/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 2 +- src/py/reactpy/reactpy/utils.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 97c56cbb8..87e605231 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -519,7 +519,7 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) + return current_hook().use_state(function, server_only=True) def _try_to_infer_closure_values( diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index 4f28f8da2..8c8e4b8eb 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -27,7 +27,7 @@ class Ref(Generic[_RefValue]): You can compare the contents for two ``Ref`` objects using the ``==`` operator. """ - __slots__ = ("current", "key") + __slots__ = ("current", "key", "_hook") def __init__( self, initial_value: _RefValue = _UNDEFINED, key: str | None = None @@ -38,9 +38,11 @@ def __init__( self.current = initial_value """The present value""" self.key = key + self._hook = None if key: hook = current_hook() hook.add_state_update(self) + self._hook = hook @property def value(self) -> _RefValue: @@ -51,13 +53,10 @@ def set_current(self, new: _RefValue) -> _RefValue: This is nice to use in ``lambda`` functions. """ - from reactpy.core._life_cycle_hook import current_hook - old = self.current self.current = new if self.key: - hook = current_hook() - hook.add_state_update(self) + self._hook.add_state_update(self) return old def __eq__(self, other: Any) -> bool: From 25682b270ae0c0154b6e828ce881544dc16c4032 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:45:03 +0000 Subject: [PATCH 069/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 87e605231..97c56cbb8 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -519,7 +519,7 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function, server_only=True) + return current_hook().use_state(function) def _try_to_infer_closure_values( From b48910a81bc403028e32075437a8d4547a182b05 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:50:00 +0000 Subject: [PATCH 070/166] state_var_lock --- src/py/reactpy/reactpy/core/layout.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index a16118e7f..4c9d963e1 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -10,9 +10,11 @@ get_running_loop, wait, ) +import asyncio from collections import Counter from collections.abc import Sequence from contextlib import AsyncExitStack +import copy from logging import getLogger from typing import ( Any, @@ -83,6 +85,7 @@ def __init__( self.reconnecting = Ref(False) self._state_recovery_serializer = None self.client_state = {} + self._state_var_lock = asyncio.Lock() def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer @@ -186,17 +189,19 @@ async def _create_layout_update( if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) + with self._state_var_lock: + tmp_state_vars = copy.copy(new_state.life_cycle_state.hook._updated_states) + new_state.life_cycle_state.hook._updated_states.clear() + state_vars = ( + self._state_recovery_serializer.serialize_state_vars(tmp_state_vars) + if self._state_recovery_serializer + else {} + ) return LayoutUpdateMessage( type="layout-update", path=new_state.patch_path, model=new_state.model.current, - state_vars=( - self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._updated_states - ) - if self._state_recovery_serializer - else {} - ), + state_vars=state_vars, ) async def _render_component( From 126234246b9b7c2c7a792ee72981e006ced64dd9 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:50:52 +0000 Subject: [PATCH 071/166] wip --- src/py/reactpy/reactpy/core/layout.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 4c9d963e1..f505cd5dd 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -68,6 +68,7 @@ class Layout: "reconnecting", "client_state", "_state_recovery_serializer", + "_state_var_lock", ) if not hasattr(abc.ABC, "__weakref__"): # nocov From 3e9a921a36a14c1fdd77a7d15e89ed01a37502aa Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:51:04 +0000 Subject: [PATCH 072/166] organize imports --- src/py/reactpy/reactpy/core/layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f505cd5dd..2b8e40327 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -1,6 +1,8 @@ from __future__ import annotations import abc +import asyncio +import copy from asyncio import ( FIRST_COMPLETED, CancelledError, @@ -10,11 +12,9 @@ get_running_loop, wait, ) -import asyncio from collections import Counter from collections.abc import Sequence from contextlib import AsyncExitStack -import copy from logging import getLogger from typing import ( Any, From cecdd85a2977c72661053d0f0fe74e7edb2d5b3d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:52:59 +0000 Subject: [PATCH 073/166] wip --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 2b8e40327..14b2a1116 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -190,7 +190,7 @@ async def _create_layout_update( if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) - with self._state_var_lock: + async with self._state_var_lock: tmp_state_vars = copy.copy(new_state.life_cycle_state.hook._updated_states) new_state.life_cycle_state.hook._updated_states.clear() state_vars = ( From c885c2da16d555427a5fb20f91f047042f8b6138 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 00:56:55 +0000 Subject: [PATCH 074/166] stop logging state vars --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 149f177db..a528ca8a0 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -62,7 +62,6 @@ export abstract class BaseReactPyClient implements ReactPyClient { updateStateVars(givenStateVars: object): void { this.stateVars = Object.assign(this.stateVars, givenStateVars); - logger.log(this.stateVars); } /** From 81a2ff1210f8aec9db3c848fe0eec21197774f3c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:10:44 +0000 Subject: [PATCH 075/166] reconnect refactor --- src/py/reactpy/reactpy/__init__.py | 2 ++ src/py/reactpy/reactpy/backend/hooks.py | 10 ++++++++-- src/py/reactpy/reactpy/core/hooks.py | 10 ++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 8b5bf22ef..9b52e8ede 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -12,6 +12,7 @@ use_debug_value, use_effect, use_memo, + use_reconnect_effect, use_reducer, use_ref, use_state, @@ -48,6 +49,7 @@ "use_effect", "use_location", "use_memo", + "use_reconnect_effect", "use_reducer", "use_ref", "use_scope", diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index 15ece0cb3..bde6aed20 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -4,7 +4,13 @@ from typing import Any, Callable from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ReconnectingOnly, create_context, use_context, use_effect, _EffectApplyFunc +from reactpy.core.hooks import ( + ReconnectingOnly, + create_context, + use_context, + use_effect, + _EffectApplyFunc, +) from reactpy.core.types import Context # backend implementations should establish this context at the root of an app @@ -34,4 +40,4 @@ def use_reconnect_effect( function: _EffectApplyFunc | None = None, ) -> Callable[[_EffectApplyFunc], None] | None: """Apply an effect only on reconnection""" - return use_effect(function, ReconnectingOnly) \ No newline at end of file + return use_effect(function, ReconnectingOnly()) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 97c56cbb8..5cce3591e 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -43,8 +43,10 @@ logger = getLogger(__name__) -# Faux object used for dependency of reconnecting -ReconnectingOnly = ["reconnecting-faux-class"] +class ReconnectingOnly(list): + """ + Used to indicate that a hook should only be used during reconnection + """ _Type = TypeVar("_Type") @@ -175,11 +177,11 @@ def use_effect( last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) hook = current_hook() if hook.reconnecting.current: - if dependencies is not ReconnectingOnly: + if not isinstance(dependencies, ReconnectingOnly): return dependencies = None else: - if dependencies is ReconnectingOnly: + if isinstance(dependencies, ReconnectingOnly): return dependencies = _try_to_infer_closure_values(function, dependencies) From 465ce37c2c7e7e87c8f3f153e81ef91eb611ce72 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:12:51 +0000 Subject: [PATCH 076/166] wip --- src/py/reactpy/reactpy/__init__.py | 8 ++++++-- src/py/reactpy/reactpy/backend/hooks.py | 2 +- src/py/reactpy/reactpy/core/hooks.py | 3 +-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 9b52e8ede..32b4712a1 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -1,5 +1,10 @@ from reactpy import backend, config, html, logging, sample, svg, types, web, widgets -from reactpy.backend.hooks import use_connection, use_location, use_scope +from reactpy.backend.hooks import ( + use_connection, + use_location, + use_reconnect_effect, + use_scope, +) from reactpy.backend.utils import run from reactpy.core import hooks from reactpy.core.component import component @@ -12,7 +17,6 @@ use_debug_value, use_effect, use_memo, - use_reconnect_effect, use_reducer, use_ref, use_state, diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index bde6aed20..3424b9b86 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -6,10 +6,10 @@ from reactpy.backend.types import Connection, Location from reactpy.core.hooks import ( ReconnectingOnly, + _EffectApplyFunc, create_context, use_context, use_effect, - _EffectApplyFunc, ) from reactpy.core.types import Context diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 5cce3591e..de681e44f 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,11 +1,10 @@ from __future__ import annotations import asyncio +import sys from collections.abc import Coroutine, Sequence from hashlib import md5 -import inspect from logging import getLogger -import sys from types import FunctionType from typing import ( TYPE_CHECKING, From 6a317371d208ee956e68ef05f0a29f92788994c8 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:27:58 +0000 Subject: [PATCH 077/166] implement maximum state size --- src/py/reactpy/reactpy/core/state_recovery.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index a9aedf609..92361c547 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -4,6 +4,7 @@ import time from collections.abc import Iterable from decimal import Decimal +from logging import getLogger from pathlib import Path from typing import Any, Callable from uuid import UUID @@ -11,6 +12,7 @@ import orjson import pyotp +logger = getLogger(__name__) class StateRecoveryFailureError(Exception): """ @@ -25,13 +27,13 @@ def __init__( pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), - max_objects: int = 256, + max_num_state_objects: int = 256, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: self._pepper = pepper - self._max_objects = max_objects + self._max_num_state_objects = max_num_state_objects self._max_object_length = max_object_length self._otp_key = base64.b32encode( (otp_key or self._discover_otp_key()).encode("utf-8") @@ -82,6 +84,7 @@ def create_serializer( object_to_type_id=self._object_to_type_id, type_id_to_object=self._type_id_to_object, max_object_length=self._max_object_length, + max_num_state_objects=self._max_num_state_objects, default_serializer=self._default_serializer, deserializer_map=self._deserializer_map, ) @@ -97,6 +100,7 @@ def __init__( object_to_type_id: dict[Any, bytes], type_id_to_object: dict[bytes, Any], max_object_length: int, + max_num_state_objects: int, default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: @@ -112,6 +116,9 @@ def __init__( def serialize_state_vars( self, state_vars: dict[str, Any] ) -> dict[str, tuple[str, str, str]]: + if len(state_vars) > max_num_state_objects: + logger.warning(f"State is too large ({len(state_vars)}). State will not be sent") + return {} result = {} for key, value in state_vars.items(): result[key] = self._serialize(key, value) From 8e0099c6d5de62053f549fe0f6cd93e365baa0e2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:28:33 +0000 Subject: [PATCH 078/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 92361c547..02aa7c4f1 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -110,13 +110,14 @@ def __init__( self._object_to_type_id = object_to_type_id 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 {} def serialize_state_vars( self, state_vars: dict[str, Any] ) -> dict[str, tuple[str, str, str]]: - if len(state_vars) > max_num_state_objects: + if len(state_vars) > self._max_num_state_objects: logger.warning(f"State is too large ({len(state_vars)}). State will not be sent") return {} result = {} From aff15722bcf76ed04b262316e6dfcb1596984e4c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:34:26 +0000 Subject: [PATCH 079/166] wip --- src/py/reactpy/reactpy/core/layout.py | 21 +++++++++++++++++++++ src/py/reactpy/reactpy/core/serve.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 14b2a1116..2d7ed5729 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -156,6 +156,22 @@ async def render(self) -> LayoutUpdateMessage: else: # nocov return await self._serial_render() + async def render_until_queue_empty(self) -> None: + while True: + try: + model_state_id = await self._rendering_queue.get_nowait() + except asyncio.QueueEmpty: + return + try: + model_state = self._model_states_by_life_cycle_state_id[model_state_id] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{model_state_id!r} - component already unmounted" + ) + else: + await self._create_layout_update(model_state) + async def _serial_render(self) -> LayoutUpdateMessage: # nocov """Await the next available render. This will block until a component is updated""" while True: @@ -819,6 +835,11 @@ async def get(self) -> _Type: self._pending.remove(value) return value + async def get_nowait(self) -> _Type: + value = self._queue.get_nowait() + self._pending.remove(value) + return value + def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]: infos: list[_ChildInfo] = [] diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index aebe2f13e..aff370e82 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -192,7 +192,7 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: else: salt = client_state_msg["salt"] layout.start_rendering() - await layout.render() + await layout.render_until_queue_empty() layout.reconnecting.set_current(False) layout.client_state = {} return salt From 1222341a26e7d334534cbbe0bca423323b0ffea0 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:47:53 +0000 Subject: [PATCH 080/166] wip --- src/py/reactpy/reactpy/core/layout.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 2d7ed5729..404275b33 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -157,11 +157,8 @@ async def render(self) -> LayoutUpdateMessage: return await self._serial_render() async def render_until_queue_empty(self) -> None: + model_state_id = await self._rendering_queue.get() while True: - try: - model_state_id = await self._rendering_queue.get_nowait() - except asyncio.QueueEmpty: - return try: model_state = self._model_states_by_life_cycle_state_id[model_state_id] except KeyError: @@ -171,6 +168,15 @@ async def render_until_queue_empty(self) -> None: ) else: await self._create_layout_update(model_state) + for _ in range(5): + try: + model_state_id = await self._rendering_queue.get_nowait() + except asyncio.QueueEmpty: + asyncio.sleep(0.01) # make sure + else: + break + else: + return async def _serial_render(self) -> LayoutUpdateMessage: # nocov """Await the next available render. This will block until a component is updated""" From 16cedc5ef33a835c5b4de89edc3c5a5f6311ff11 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 01:54:49 +0000 Subject: [PATCH 081/166] Support events coming in late --- src/py/reactpy/reactpy/core/layout.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 404275b33..bd0e484cd 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -168,11 +168,15 @@ async def render_until_queue_empty(self) -> None: ) else: await self._create_layout_update(model_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 + # declaring it done. In the future, it would be better to just track the pending events for _ in range(5): try: model_state_id = await self._rendering_queue.get_nowait() except asyncio.QueueEmpty: - asyncio.sleep(0.01) # make sure + asyncio.sleep(0.15) # make sure else: break else: From 0083c60ce8be1fc06d94dd240b6c7d96d5ff70fe Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 02:11:55 +0000 Subject: [PATCH 082/166] support serializing and deserializing lists of objects --- src/py/reactpy/reactpy/core/state_recovery.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 02aa7c4f1..b0490977b 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -14,6 +14,7 @@ logger = getLogger(__name__) + class StateRecoveryFailureError(Exception): """ Raised when state recovery fails. @@ -118,7 +119,9 @@ def serialize_state_vars( self, state_vars: dict[str, Any] ) -> dict[str, tuple[str, str, str]]: if len(state_vars) > self._max_num_state_objects: - logger.warning(f"State is too large ({len(state_vars)}). State will not be sent") + logger.warning( + f"State is too large ({len(state_vars)}). State will not be sent" + ) return {} result = {} for key, value in state_vars.items(): @@ -129,6 +132,9 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: if obj is None: return "0", "", "" obj_type = type(obj) + if obj_type in (list, tuple): + if len(obj) != 0: + obj_type = type(obj[0]) for t in obj_type.__mro__: type_id = self._object_to_type_id.get(t) if type_id: @@ -184,11 +190,9 @@ def _sign_serialization(self, key: str, type_id: bytes, data: bytes) -> str: def _serialize_object(self, obj: Any) -> bytes: return orjson.dumps(obj, default=self._default_serializer) - def _deserialize_object(self, typ: Any, data: bytes) -> Any: - if typ is None: - return None - result = orjson.loads(data) - custom_deserializer = self._deserializer_map.get(typ) + def _do_deserialize( + self, typ: type, result: Any, custom_deserializer: Callable | None + ) -> Any: if custom_deserializer: return custom_deserializer(result) if isinstance(result, str): @@ -196,3 +200,14 @@ def _deserialize_object(self, typ: Any, data: bytes) -> Any: if isinstance(result, dict): return typ(**result) return result + + def _deserialize_object(self, typ: Any, data: bytes) -> Any: + if typ is None and not data: + return None + result = orjson.loads(data) + custom_deserializer = self._deserializer_map.get(typ) + if type(result) in (list, tuple): + return [ + self._do_deserialize(typ, item, custom_deserializer) for item in result + ] + return self._do_deserialize(typ, result, custom_deserializer) From a5306c73bc1def2b085390370902d5c051e976da Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 02:18:44 +0000 Subject: [PATCH 083/166] wip --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index bd0e484cd..8f934f401 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -176,7 +176,7 @@ async def render_until_queue_empty(self) -> None: try: model_state_id = await self._rendering_queue.get_nowait() except asyncio.QueueEmpty: - asyncio.sleep(0.15) # make sure + await asyncio.sleep(0.15) # make sure else: break else: From 25a1508f7ee1774bbe94a6980f1dbba8f0aea307 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 02:43:13 +0000 Subject: [PATCH 084/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index b0490977b..70f521558 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -24,7 +24,7 @@ class StateRecoveryFailureError(Exception): class StateRecoveryManager: def __init__( self, - serializable_objects: Iterable[type], + serializable_types: Iterable[type], pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), @@ -45,7 +45,7 @@ def __init__( self._map_objects_to_ids( [ - *list(serializable_objects), + *list(serializable_types), Decimal, datetime.datetime, datetime.date, @@ -53,11 +53,11 @@ def __init__( ] ) - def _map_objects_to_ids(self, serializable_objects: Iterable[type]) -> dict: + 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, str, int, float, bool, list, tuple, UUID, *serializable_objects) + (None, str, int, float, bool, list, tuple, UUID, *serializable_types) ): idx_as_bytes = str(idx).encode("utf-8") self._object_to_type_id[typ] = idx_as_bytes @@ -140,7 +140,9 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: if type_id: break else: - raise ValueError(f"Object {obj} was not white-listed for serialization") + raise ValueError( + f"Objects of type {obj_type} was not part of serializable_types" + ) result = self._serialize_object(obj) if len(result) > self._max_object_length: raise ValueError( From 04ca431c19a17cbde23cca4341520e63fe684436 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 17:11:23 +0000 Subject: [PATCH 085/166] increase OTP digits --- src/py/reactpy/reactpy/core/state_recovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 70f521558..9401596c9 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -28,6 +28,7 @@ def __init__( pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), + otp_digits: int = 32, max_num_state_objects: int = 256, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, @@ -39,7 +40,7 @@ def __init__( self._otp_key = base64.b32encode( (otp_key or self._discover_otp_key()).encode("utf-8") ) - self._totp = pyotp.TOTP(self._otp_key, interval=otp_interval) + self._totp = pyotp.TOTP(self._otp_key, digits=otp_digits, interval=otp_interval) self._default_serializer = default_serializer self._deserializer_map = deserializer_map or {} From c60e2913eb98f067b21d4d36917e20af1929a96d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 17:11:42 +0000 Subject: [PATCH 086/166] Change state key to sha256 with digits thrown away --- src/py/reactpy/reactpy/core/hooks.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index de681e44f..4a1930886 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from functools import lru_cache +import hashlib import sys from collections.abc import Coroutine, Sequence from hashlib import md5 @@ -78,7 +80,7 @@ def use_state( else: hook = current_hook() caller_info = get_caller_info() - key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() + key = get_state_key(caller_info) if hook.reconnecting.current: try: initial_value = hook.client_state[key] @@ -90,6 +92,10 @@ def use_state( return State(current_state.value, current_state.dispatch) + +def sha256_hexdigest(s: str) -> str: + + def get_caller_info(): # Get the current stack frame and then the frame above it caller_frame = sys._getframe(2) @@ -98,8 +104,21 @@ 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 - return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" + # Extract the relevant information: file path and line number and hash it + return sha256_hexdigest( + f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" + ) + + +__DEBUG_CALLER_INFO_TO_STATE_KEY = {} + + +@lru_cache(8192) +def get_state_key(caller_info: str) -> str: + result = hashlib.sha256(caller_info.encode("utf8")).hexdigest()[:20] + if __debug__: + __DEBUG_CALLER_INFO_TO_STATE_KEY[result] = caller_info + return result class _CurrentState(Generic[_Type]): @@ -508,7 +527,7 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: else: hook = current_hook() caller_info = get_caller_info() - key = md5(caller_info.encode(), usedforsecurity=False).hexdigest() + key = get_state_key(caller_info) if hook.reconnecting.current: try: initial_value = hook.client_state[key] From 0d3df4a667dd7f39d13da1ff857b3bbd32ee60de Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sat, 2 Mar 2024 17:30:05 +0000 Subject: [PATCH 087/166] Add ability to check older and future codes --- src/py/reactpy/reactpy/core/state_recovery.py | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 9401596c9..a60d7f8e3 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -29,6 +29,7 @@ def __init__( otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), otp_digits: int = 32, + otp_max_age: int = (48 * 60 * 60), max_num_state_objects: int = 256, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, @@ -41,6 +42,7 @@ def __init__( (otp_key or self._discover_otp_key()).encode("utf-8") ) self._totp = pyotp.TOTP(self._otp_key, digits=otp_digits, interval=otp_interval) + self._otp_max_age = otp_max_age self._default_serializer = default_serializer self._deserializer_map = deserializer_map or {} @@ -80,7 +82,9 @@ def create_serializer( self, salt: str, target_time: float | None = None ) -> "StateRecoverySerializer": return StateRecoverySerializer( - otp_code=self._totp.at(target_time or time.time()), + totp=self._totp, + target_time=target_time, + otp_max_age=otp_max_age, pepper=self._pepper, salt=salt, object_to_type_id=self._object_to_type_id, @@ -96,7 +100,9 @@ class StateRecoverySerializer: def __init__( self, - otp_code: str, + totp: pyotp.TOTP, + target_time: float | None, + otp_max_age: int, pepper: str, salt: str, object_to_type_id: dict[Any, bytes], @@ -106,7 +112,12 @@ def __init__( default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: + target_time = target_time or time.time() + otp_code = totp.at(target_time) + self._target_time = target_time + self._otp_max_age = otp_max_age self._otp_code = otp_code.encode("utf-8") + self._totp = totp self._pepper = pepper.encode("utf-8") self._salt = salt.encode("utf-8") self._object_to_type_id = object_to_type_id @@ -177,15 +188,41 @@ def _deserialize( result = base64.urlsafe_b64decode(data) expected_signature = self._sign_serialization(key, type_id, result) if expected_signature != signature: - raise StateRecoveryFailureError(f"Signature mismatch for type id {type_id}") + if not self._try_future_code(key, type_id, result, signature): + if not self._try_older_codes_and_see_if_one_checks_out( + key, type_id, result, signature + ): + raise StateRecoveryFailureError( + f"Signature mismatch for type id {type_id}" + ) return self._deserialize_object(typ, result) - def _sign_serialization(self, key: str, type_id: bytes, data: bytes) -> str: + def _try_future_code( + self, key: str, type_id: bytes, data: bytes, signature: str + ) -> bool: + future_time = self._target_time + self._totp.interval + otp_code = self._totp.at(future_time).encode("utf-8") + return self._sign_serialization(key, type_id, data, otp_code) == signature + + def _try_older_codes_and_see_if_one_checks_out( + self, key: str, type_id: bytes, data: bytes, signature: str + ) -> bool: + while True: + past_time = self._target_time - self._totp.interval + otp_code = self._totp.at(past_time).encode("utf-8") + if self._sign_serialization(key, type_id, data, otp_code) == signature: + return True + if past_time < self._target_time - self._otp_max_age: + return False + + def _sign_serialization( + self, key: str, type_id: bytes, data: bytes, otp_code: bytes | None = None + ) -> str: hasher = hashlib.sha256() hasher.update(type_id) hasher.update(data) hasher.update(self._pepper) - hasher.update(self._otp_code) + hasher.update(otp_code or self._otp_code) hasher.update(self._salt) hasher.update(key.encode("utf-8")) return hasher.hexdigest() From e9bb90f207e83d722f52549548426e83ed2c11c7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:03:35 +0000 Subject: [PATCH 088/166] Switch from ThreadLocal to ContextVar --- .../reactpy/reactpy/core/_life_cycle_hook.py | 21 ++++++++++++++----- src/py/reactpy/reactpy/core/_thread_local.py | 21 ------------------- src/py/reactpy/reactpy/core/layout.py | 11 +++++++++- 3 files changed, 26 insertions(+), 27 deletions(-) delete mode 100644 src/py/reactpy/reactpy/core/_thread_local.py diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 3df66fa38..b129961c6 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -2,11 +2,11 @@ import logging from asyncio import Event, Task, create_task, gather +from contextvars import ContextVar, Token from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar from anyio import Semaphore -from reactpy.core._thread_local import ThreadLocal from reactpy.core.types import ComponentType, Context, ContextProviderType from reactpy.utils import Ref @@ -22,12 +22,23 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) +_hook_state = ContextVar("reactpy_hook_state") + + +def create_hook_state() -> Token[list]: + return _hook_state.set([]) + + +def clear_hook_state(token: Token[list]) -> None: + hook_stack = _hook_state.get() + if hook_stack: + logger.warning("clear_hook_state: Hook stack was not empty") + _hook_state.reset(token) def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - hook_stack = _HOOK_STATE.get() + hook_stack = _hook_state.get() if not hook_stack: msg = "No life cycle hook is active. Are you rendering in a layout?" raise RuntimeError(msg) @@ -249,7 +260,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _HOOK_STATE.get() + hook_stack = _hook_state.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -257,5 +268,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _HOOK_STATE.get().pop() is not self: + if _hook_state.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/py/reactpy/reactpy/core/_thread_local.py b/src/py/reactpy/reactpy/core/_thread_local.py deleted file mode 100644 index b3d6a14b0..000000000 --- a/src/py/reactpy/reactpy/core/_thread_local.py +++ /dev/null @@ -1,21 +0,0 @@ -from threading import Thread, current_thread -from typing import Callable, Generic, TypeVar -from weakref import WeakKeyDictionary - -_StateType = TypeVar("_StateType") - - -class ThreadLocal(Generic[_StateType]): - """Utility for managing per-thread state information""" - - def __init__(self, default: Callable[[], _StateType]): - self._default = default - self._state: WeakKeyDictionary[Thread, _StateType] = WeakKeyDictionary() - - def get(self) -> _StateType: - thread = current_thread() - if thread not in self._state: - state = self._state[thread] = self._default() - else: - state = self._state[thread] - return state diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 8f934f401..71dd8ccf6 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -36,7 +36,11 @@ REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE, ) -from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core._life_cycle_hook import ( + LifeCycleHook, + clear_hook_state, + create_hook_state, +) from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( ComponentType, @@ -69,6 +73,7 @@ class Layout: "client_state", "_state_recovery_serializer", "_state_var_lock", + "_hook_state_token", ) if not hasattr(abc.ABC, "__weakref__"): # nocov @@ -92,6 +97,8 @@ def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer async def __aenter__(self) -> Layout: + self._hook_state_token = create_hook_state() + # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() @@ -128,6 +135,8 @@ async def __aexit__(self, *exc: Any) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id + clear_hook_state(self._hook_state_token) + def start_rendering(self) -> None: self._schedule_render_task(self._root_life_cycle_state_id) From 5798fd1008e606d83dfdf082eef86e2168a43c2e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:42:10 +0000 Subject: [PATCH 089/166] changes for async support --- .../reactpy/reactpy/core/_life_cycle_hook.py | 6 +++--- src/py/reactpy/reactpy/core/hooks.py | 18 +++++++++--------- src/py/reactpy/reactpy/core/layout.py | 16 +++++++++------- src/py/reactpy/reactpy/testing/common.py | 4 ++-- src/py/reactpy/reactpy/utils.py | 4 ++-- src/py/reactpy/tests/test_core/test_layout.py | 6 +++--- src/py/reactpy/tests/tooling/hooks.py | 4 ++-- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index b129961c6..a1028d6d7 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -25,8 +25,8 @@ async def __call__(self, stop: Event) -> None: ... _hook_state = ContextVar("reactpy_hook_state") -def create_hook_state() -> Token[list]: - return _hook_state.set([]) +def create_hook_state(initial: list | None = None) -> Token[list]: + return _hook_state.set(initial or []) def clear_hook_state(token: Token[list]) -> None: @@ -36,7 +36,7 @@ def clear_hook_state(token: Token[list]) -> None: _hook_state.reset(token) -def current_hook() -> LifeCycleHook: +def get_current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" hook_stack = _hook_state.get() if not hook_stack: diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 4a1930886..a41dde6e7 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -22,7 +22,7 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._life_cycle_hook import current_hook +from reactpy.core._life_cycle_hook import get_current_hook from reactpy.core.state_recovery import StateRecoveryFailureError from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref @@ -78,7 +78,7 @@ def use_state( if server_only: key = None else: - hook = current_hook() + hook = get_current_hook() caller_info = get_caller_info() key = get_state_key(caller_info) if hook.reconnecting.current: @@ -135,7 +135,7 @@ def __init__( else: self.value = initial_value - hook = current_hook() + hook = get_current_hook() hook.add_state_update(self) def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: @@ -193,7 +193,7 @@ def use_effect( """ memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) - hook = current_hook() + hook = get_current_hook() if hook.reconnecting.current: if not isinstance(dependencies, ReconnectingOnly): return @@ -270,7 +270,7 @@ def use_debug_value( if REACTPY_DEBUG_MODE.current and old.current != new: old.current = new - logger.debug(f"{current_hook().component} {new}") + logger.debug(f"{get_current_hook().component} {new}") def create_context(default_value: _Type) -> Context[_Type]: @@ -298,7 +298,7 @@ def use_context(context: Context[_Type]) -> _Type: See the full :ref:`Use Context` docs for more information. """ - hook = current_hook() + hook = get_current_hook() provider = hook.get_context_provider(context) if provider is None: @@ -328,7 +328,7 @@ def __init__( self.value = value def render(self) -> VdomDict: - current_hook().set_context_provider(self) + get_current_hook().set_context_provider(self) return {"tagName": "", "children": self.children} def __repr__(self) -> str: @@ -525,7 +525,7 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: if server_only: key = None else: - hook = current_hook() + hook = get_current_hook() caller_info = get_caller_info() key = get_state_key(caller_info) if hook.reconnecting.current: @@ -539,7 +539,7 @@ def use_ref(initial_value: _Type, server_only: bool = True) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) + return get_current_hook().use_state(function) def _try_to_infer_closure_values( diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 71dd8ccf6..bcebf5271 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -40,6 +40,7 @@ LifeCycleHook, clear_hook_state, create_hook_state, + get_current_hook, ) from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( @@ -91,7 +92,6 @@ def __init__( self.reconnecting = Ref(False) self._state_recovery_serializer = None self.client_state = {} - self._state_var_lock = asyncio.Lock() def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer @@ -203,7 +203,7 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov f"{model_state_id!r} - component already unmounted" ) else: - return await self._create_layout_update(model_state) + return await self._create_layout_update(model_state, get_current_hook()) async def _concurrent_render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" @@ -214,8 +214,9 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: return update_task.result() async def _create_layout_update( - self, old_state: _ModelState + self, old_state: _ModelState, incoming_hook_state: list ) -> LayoutUpdateMessage: + token = create_hook_state(copy.copy(incoming_hook_state)) new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component @@ -225,14 +226,15 @@ async def _create_layout_update( if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) - async with self._state_var_lock: - tmp_state_vars = copy.copy(new_state.life_cycle_state.hook._updated_states) - new_state.life_cycle_state.hook._updated_states.clear() state_vars = ( - self._state_recovery_serializer.serialize_state_vars(tmp_state_vars) + self._state_recovery_serializer.serialize_state_vars( + new_state.life_cycle_state.hook._updated_states + ) if self._state_recovery_serializer else {} ) + new_state.life_cycle_state.hook._updated_states.clear() + clear_hook_state(token) return LayoutUpdateMessage( type="layout-update", path=new_state.patch_path, diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index c1eb18ba5..84f3243ae 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -13,7 +13,7 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR -from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook +from reactpy.core._life_cycle_hook import LifeCycleHook, get_current_hook from reactpy.core.events import EventHandler, to_event_handler_function @@ -143,7 +143,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if self is None: raise RuntimeError("Hook catcher has been garbage collected") - hook = current_hook() + hook = get_current_hook() if self.index_by_kwarg is not None: self.index[kwargs[self.index_by_kwarg]] = hook self.latest = hook diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index 8c8e4b8eb..06599c445 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -32,7 +32,7 @@ class Ref(Generic[_RefValue]): def __init__( self, initial_value: _RefValue = _UNDEFINED, key: str | None = None ) -> None: - from reactpy.core._life_cycle_hook import current_hook + from reactpy.core._life_cycle_hook import get_current_hook if initial_value is not _UNDEFINED: self.current = initial_value @@ -40,7 +40,7 @@ def __init__( self.key = key self._hook = None if key: - hook = current_hook() + hook = get_current_hook() hook.add_state_update(self) self._hook = hook diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index cfb544758..276e36e2d 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -343,7 +343,7 @@ async def test_root_component_life_cycle_hook_is_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.get_current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -375,7 +375,7 @@ async def test_life_cycle_hooks_are_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.get_current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -625,7 +625,7 @@ def Outer(): @reactpy.component def Inner(finalizer_id): if finalizer_id not in registered_finalizers: - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.get_current_hook() finalize(hook, lambda: garbage_collect_items.append(finalizer_id)) registered_finalizers.add(finalizer_id) return reactpy.html.div(finalizer_id) diff --git a/src/py/reactpy/tests/tooling/hooks.py b/src/py/reactpy/tests/tooling/hooks.py index 1926a93bc..b60040495 100644 --- a/src/py/reactpy/tests/tooling/hooks.py +++ b/src/py/reactpy/tests/tooling/hooks.py @@ -1,8 +1,8 @@ -from reactpy.core.hooks import current_hook, use_state +from reactpy.core.hooks import get_current_hook, use_state def use_force_render(): - return current_hook().schedule_render + return get_current_hook().schedule_render def use_toggle(init=False): From df2e137af77f4d6f5b85dc05cddbff6a50ec3a84 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:44:33 +0000 Subject: [PATCH 090/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a41dde6e7..c6269d07b 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -92,10 +92,6 @@ def use_state( return State(current_state.value, current_state.dispatch) - -def sha256_hexdigest(s: str) -> str: - - def get_caller_info(): # Get the current stack frame and then the frame above it caller_frame = sys._getframe(2) From 87b949333903de6a928540807c91b97cbc0e5f51 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:44:48 +0000 Subject: [PATCH 091/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index c6269d07b..5dd1bef4d 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -101,9 +101,7 @@ def get_caller_info(): if patch_path is not None: break # Extract the relevant information: file path and line number and hash it - return sha256_hexdigest( - f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" - ) + return f"{caller_frame.f_code.co_filename} {caller_frame.f_lineno} {patch_path}" __DEBUG_CALLER_INFO_TO_STATE_KEY = {} From 3100b804437b92dc73edbdd07ba597089c35fd59 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:47:21 +0000 Subject: [PATCH 092/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index a60d7f8e3..f4c0fb368 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -28,7 +28,7 @@ def __init__( pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), - otp_digits: int = 32, + otp_digits: int = 10, otp_max_age: int = (48 * 60 * 60), max_num_state_objects: int = 256, max_object_length: int = 40000, From 60b28f6c729c9f8fc0add3e1e18b10f9cb340cbf Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:48:21 +0000 Subject: [PATCH 093/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index f4c0fb368..038974980 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -84,7 +84,7 @@ def create_serializer( return StateRecoverySerializer( totp=self._totp, target_time=target_time, - otp_max_age=otp_max_age, + otp_max_age=self._otp_max_age, pepper=self._pepper, salt=salt, object_to_type_id=self._object_to_type_id, From 6b76db055bee80cc574902454b8aab93cd0fcfe7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 04:01:48 +0000 Subject: [PATCH 094/166] wip --- .../reactpy/reactpy/core/_life_cycle_hook.py | 8 ++++-- src/py/reactpy/reactpy/core/layout.py | 6 ++--- src/py/reactpy/reactpy/core/serve.py | 27 +++++++++++-------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index a1028d6d7..e26e048ae 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -36,6 +36,10 @@ def clear_hook_state(token: Token[list]) -> None: _hook_state.reset(token) +def get_hook_state() -> list[LifeCycleHook]: + return _hook_state.get() + + def get_current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" hook_stack = _hook_state.get() @@ -260,7 +264,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _hook_state.get() + hook_stack = get_hook_state() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -268,5 +272,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _hook_state.get().pop() is not self: + if get_hook_state().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index bcebf5271..b21153d88 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -40,7 +40,7 @@ LifeCycleHook, clear_hook_state, create_hook_state, - get_current_hook, + get_hook_state, ) from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( @@ -176,7 +176,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) + 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 @@ -203,7 +203,7 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov f"{model_state_id!r} - component already unmounted" ) else: - return await self._create_layout_update(model_state, get_current_hook()) + return await self._create_layout_update(model_state, get_hook_state()) async def _concurrent_render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index aff370e82..2b77abbdc 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -13,6 +13,7 @@ from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.core._life_cycle_hook import clear_hook_state, create_hook_state from reactpy.core.layout import Layout from reactpy.core.state_recovery import StateRecoveryFailureError, StateRecoveryManager from reactpy.core.types import ( @@ -82,18 +83,22 @@ async def _single_outgoing_loop( layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine ) -> None: while True: - update = await layout.render() + token = create_hook_state() try: - await send(update) - except Exception: # nocov - if not REACTPY_DEBUG_MODE.current: - msg = ( - "Failed to send update. More info may be available " - "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." - ) - logger.error(msg) - raise + update = await layout.render() + try: + await send(update) + except Exception: # nocov + if not REACTPY_DEBUG_MODE.current: + msg = ( + "Failed to send update. More info may be available " + "if you enabling debug mode by setting " + "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." + ) + logger.error(msg) + raise + finally: + clear_hook_state(token) async def _single_incoming_loop( From fc6066839ef0c044cdcdf4671934842533ecfa02 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 07:15:56 +0000 Subject: [PATCH 095/166] add otp mixer --- src/py/reactpy/reactpy/core/state_recovery.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 038974980..5450b55a9 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -28,8 +28,12 @@ def __init__( pepper: str, otp_key: str | None = None, otp_interval: int = (4 * 60 * 60), - otp_digits: int = 10, + otp_digits: int = 10, # 10 is the max allowed otp_max_age: int = (48 * 60 * 60), + # OTP code is actually three codes, in the past and future concatenated + otp_mixer: float = ( + 365 * 24 * 60 * 60 * 3 + ), max_num_state_objects: int = 256, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, @@ -45,6 +49,7 @@ def __init__( self._otp_max_age = otp_max_age self._default_serializer = default_serializer self._deserializer_map = deserializer_map or {} + self._otp_mixer = otp_mixer self._map_objects_to_ids( [ @@ -85,6 +90,7 @@ def create_serializer( totp=self._totp, target_time=target_time, otp_max_age=self._otp_max_age, + otp_mixer=self._otp_mixer, pepper=self._pepper, salt=salt, object_to_type_id=self._object_to_type_id, @@ -103,6 +109,7 @@ def __init__( totp: pyotp.TOTP, target_time: float | None, otp_max_age: int, + otp_mixer: float, pepper: str, salt: str, object_to_type_id: dict[Any, bytes], @@ -113,11 +120,12 @@ def __init__( deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: target_time = target_time or time.time() + self._totp = totp + self._otp_mixer = otp_mixer otp_code = totp.at(target_time) self._target_time = target_time self._otp_max_age = otp_max_age self._otp_code = otp_code.encode("utf-8") - self._totp = totp self._pepper = pepper.encode("utf-8") self._salt = salt.encode("utf-8") self._object_to_type_id = object_to_type_id @@ -127,6 +135,13 @@ def __init__( self._default_serializer = default_serializer self._deserializer_map = deserializer_map or {} + def _get_otp_code(self, target_time: float) -> str: + return ( + self._totp.at(target_time) + + self._totp.at(target_time - self._otp_mixer) + + self._totp.at(target_time + self._otp_mixer) + ) + def serialize_state_vars( self, state_vars: dict[str, Any] ) -> dict[str, tuple[str, str, str]]: From 0e90992f20573fa783df48b49d90a51fdc85a59b Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 07:20:09 +0000 Subject: [PATCH 096/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 5450b55a9..f7002daf9 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -31,9 +31,7 @@ def __init__( otp_digits: int = 10, # 10 is the max allowed otp_max_age: int = (48 * 60 * 60), # OTP code is actually three codes, in the past and future concatenated - otp_mixer: float = ( - 365 * 24 * 60 * 60 * 3 - ), + otp_mixer: float = (365 * 24 * 60 * 60 * 3), max_num_state_objects: int = 256, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, @@ -119,11 +117,11 @@ def __init__( default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, ) -> None: - target_time = target_time or time.time() self._totp = totp self._otp_mixer = otp_mixer - otp_code = totp.at(target_time) + target_time = target_time or time.time() self._target_time = target_time + otp_code = self._get_otp_code(target_time) self._otp_max_age = otp_max_age self._otp_code = otp_code.encode("utf-8") self._pepper = pepper.encode("utf-8") @@ -136,11 +134,8 @@ def __init__( self._deserializer_map = deserializer_map or {} def _get_otp_code(self, target_time: float) -> str: - return ( - self._totp.at(target_time) - + self._totp.at(target_time - self._otp_mixer) - + self._totp.at(target_time + self._otp_mixer) - ) + at = self._totp.at + return f"{at(target_time)}{at(target_time - self._otp_mixer)}{at(target_time + self._otp_mixer)}" def serialize_state_vars( self, state_vars: dict[str, Any] @@ -216,7 +211,7 @@ def _try_future_code( self, key: str, type_id: bytes, data: bytes, signature: str ) -> bool: future_time = self._target_time + self._totp.interval - otp_code = self._totp.at(future_time).encode("utf-8") + otp_code = self._get_otp_code(future_time).encode("utf-8") return self._sign_serialization(key, type_id, data, otp_code) == signature def _try_older_codes_and_see_if_one_checks_out( @@ -224,7 +219,7 @@ def _try_older_codes_and_see_if_one_checks_out( ) -> bool: while True: past_time = self._target_time - self._totp.interval - otp_code = self._totp.at(past_time).encode("utf-8") + otp_code = self._get_otp_code(past_time).encode("utf-8") if self._sign_serialization(key, type_id, data, otp_code) == signature: return True if past_time < self._target_time - self._otp_max_age: From 675966fdf7e4f2e2da8c53d3f91a645f9f6f41d0 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 07:23:57 +0000 Subject: [PATCH 097/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index f7002daf9..ecbeb6cad 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -217,13 +217,15 @@ def _try_future_code( def _try_older_codes_and_see_if_one_checks_out( self, key: str, type_id: bytes, data: bytes, signature: str ) -> bool: - while True: - past_time = self._target_time - self._totp.interval + past_time = self._target_time + for _ in range(100): + past_time -= self._totp.interval otp_code = self._get_otp_code(past_time).encode("utf-8") if self._sign_serialization(key, type_id, data, otp_code) == signature: return True if past_time < self._target_time - self._otp_max_age: return False + raise RuntimeError("Too many iterations: _try_older_codes_and_see_if_one_checks_out") def _sign_serialization( self, key: str, type_id: bytes, data: bytes, otp_code: bytes | None = None From cee390dd5e910058c4d2928657c97248eaa49fcf Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:29:15 +0000 Subject: [PATCH 098/166] refactor --- src/py/reactpy/reactpy/core/layout.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index b21153d88..4b17bf9d3 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -97,8 +97,6 @@ def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer async def __aenter__(self) -> Layout: - self._hook_state_token = create_hook_state() - # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() @@ -111,8 +109,6 @@ async def __aenter__(self) -> Layout: self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} - # TODO: what to do with this? - # self._schedule_render_task(root_id) return self @@ -135,8 +131,6 @@ async def __aexit__(self, *exc: Any) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id - clear_hook_state(self._hook_state_token) - def start_rendering(self) -> None: self._schedule_render_task(self._root_life_cycle_state_id) @@ -176,7 +170,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()) + await self._create_layout_update(model_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 @@ -203,7 +197,7 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov f"{model_state_id!r} - component already unmounted" ) else: - return await self._create_layout_update(model_state, get_hook_state()) + return await self._create_layout_update(model_state) async def _concurrent_render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" @@ -214,9 +208,9 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: return update_task.result() async def _create_layout_update( - self, old_state: _ModelState, incoming_hook_state: list + self, old_state: _ModelState ) -> LayoutUpdateMessage: - token = create_hook_state(copy.copy(incoming_hook_state)) + token = create_hook_state() new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component @@ -233,6 +227,7 @@ async def _create_layout_update( if self._state_recovery_serializer else {} ) + # TODO: refactor to context var new_state.life_cycle_state.hook._updated_states.clear() clear_hook_state(token) return LayoutUpdateMessage( From 91ff583526a439eb6e1332eae96a3ac49ff79b1d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:54:46 +0000 Subject: [PATCH 099/166] refactor to use contextvar --- src/py/reactpy/reactpy/core/layout.py | 35 ++++++--------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 4b17bf9d3..003e488fc 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -39,8 +39,10 @@ from reactpy.core._life_cycle_hook import ( LifeCycleHook, clear_hook_state, + clear_state_updates, create_hook_state, - get_hook_state, + create_state_updates, + get_state_updates, ) from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( @@ -210,7 +212,8 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: async def _create_layout_update( self, old_state: _ModelState ) -> LayoutUpdateMessage: - token = create_hook_state() + hook_stack_token = create_hook_state() + state_updates_token = create_state_updates() new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component @@ -221,15 +224,12 @@ async def _create_layout_update( validate_vdom_json(new_state.model.current) state_vars = ( - self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._updated_states - ) + self._state_recovery_serializer.serialize_state_vars(get_state_updates()) if self._state_recovery_serializer else {} ) - # TODO: refactor to context var - new_state.life_cycle_state.hook._updated_states.clear() - clear_hook_state(token) + clear_hook_state(hook_stack_token) + clear_state_updates(state_updates_token) return LayoutUpdateMessage( type="layout-update", path=new_state.patch_path, @@ -596,9 +596,6 @@ def _make_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: - updated_states = ( - parent.life_cycle_state or parent.parent_life_cycle_state - ).hook._updated_states return _ModelState( parent=parent, index=index, @@ -612,7 +609,6 @@ def _make_component_model_state( schedule_render, reconnecting, client_state, - updated_states, ), ) @@ -661,9 +657,6 @@ def _update_component_model_state( schedule_render, reconnecting, client_state, - ( - new_parent.life_cycle_state or new_parent.parent_life_cycle_state - ).hook._updated_states, ) ), ) @@ -682,9 +675,6 @@ def _make_element_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, - parent_life_cycle_state=( - parent.life_cycle_state or parent.parent_life_cycle_state - ), ) @@ -701,9 +691,6 @@ def _update_element_model_state( patch_path=old_model_state.patch_path, children_by_key={}, targets_by_event={}, - parent_life_cycle_state=( - new_parent.life_cycle_state or new_parent.parent_life_cycle_state - ), ) @@ -718,7 +705,6 @@ class _ModelState: "index", "key", "life_cycle_state", - "parent_life_cycle_state", "model", "patch_path", "targets_by_event", @@ -734,7 +720,6 @@ def __init__( children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], life_cycle_state: _LifeCycleState | None = None, - parent_life_cycle_state: _LifeCycleState | None = None, ): self.index = index """The index of the element amongst its siblings""" @@ -764,8 +749,6 @@ def __init__( self.life_cycle_state = life_cycle_state """The state for the element's component (if it has one)""" - self.parent_life_cycle_state = parent_life_cycle_state - @property def is_component_state(self) -> bool: return self.life_cycle_state is not None @@ -789,7 +772,6 @@ def _make_life_cycle_state( schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any], - updated_states: dict[str, Any], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( @@ -798,7 +780,6 @@ def _make_life_cycle_state( lambda: schedule_render(life_cycle_state_id), reconnecting=reconnecting, client_state=client_state, - updated_states=updated_states, ), component, ) From ffd4dcd6669c1e5a42ed35d4d5e0b14b75b64237 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:56:19 +0000 Subject: [PATCH 100/166] wip --- .../reactpy/reactpy/core/_life_cycle_hook.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index e26e048ae..33638e2b7 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -22,11 +22,16 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_hook_state = ContextVar("reactpy_hook_state") +_hook_state = ContextVar("_hook_state") +_state_updates = ContextVar("_state_updates") -def create_hook_state(initial: list | None = None) -> Token[list]: - return _hook_state.set(initial or []) +def create_hook_state() -> Token[list]: + return _hook_state.set([]) + + +def create_state_updates() -> Token[list]: + return _state_updates.set([]) def clear_hook_state(token: Token[list]) -> None: @@ -36,10 +41,18 @@ def clear_hook_state(token: Token[list]) -> None: _hook_state.reset(token) +def clear_state_updates(token: Token[list]) -> None: + _state_updates.reset(token) + + def get_hook_state() -> list[LifeCycleHook]: return _hook_state.get() +def get_state_updates() -> list[_CurrentState]: + return _state_updates.get() + + def get_current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" hook_stack = _hook_state.get() @@ -138,7 +151,6 @@ async def my_effect(stop_event): "component", "reconnecting", "client_state", - "_updated_states", ) component: ComponentType @@ -162,11 +174,10 @@ def __init__( self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting self.client_state = client_state or {} - self._updated_states = updated_states def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if updated_state.key: - self._updated_states[updated_state.key] = updated_state.value + get_state_updates()[updated_state.key] = updated_state.value def schedule_render(self) -> None: if self._scheduled_render: From e7232396d255106e5f7c604d8c9a32a3e485478a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:57:31 +0000 Subject: [PATCH 101/166] wip --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 003e488fc..8e51380c2 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -582,7 +582,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state, {} + component, schedule_render, reconnecting, client_state ), ) From b6b77ecbecf765802bf6bf723d3df9b30f892855 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:58:28 +0000 Subject: [PATCH 102/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 33638e2b7..f21c20c2e 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -160,7 +160,6 @@ def __init__( schedule_render: Callable[[], None], reconnecting: Ref, client_state: dict[str, Any], - updated_states: dict[str, Any], ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render From 242b5297c413b7b5c7ae9f03d01396ff7fe4e998 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:00:24 +0000 Subject: [PATCH 103/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index f21c20c2e..724211c0f 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -30,8 +30,8 @@ def create_hook_state() -> Token[list]: return _hook_state.set([]) -def create_state_updates() -> Token[list]: - return _state_updates.set([]) +def create_state_updates() -> Token[dict]: + return _state_updates.set({}) def clear_hook_state(token: Token[list]) -> None: @@ -41,7 +41,7 @@ def clear_hook_state(token: Token[list]) -> None: _hook_state.reset(token) -def clear_state_updates(token: Token[list]) -> None: +def clear_state_updates(token: Token[dict]) -> None: _state_updates.reset(token) From 360243815445c5b38a7efd31d563cddb79579b9e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:07:25 +0000 Subject: [PATCH 104/166] wip --- src/py/reactpy/reactpy/core/layout.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 8e51380c2..f3865c7e3 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -145,10 +145,12 @@ async def deliver(self, event: LayoutEventMessage) -> None: handler = self._event_handlers.get(event["target"]) if handler is not None: + state_update_token = create_state_updates() try: await handler.function(event["data"]) except Exception: logger.exception(f"Failed to execute event handler {handler}") + clear_state_updates(state_update_token) else: logger.info( f"Ignored event - handler {event['target']!r} " From 6ff7bf540ccd5a2927a70cb55c74d0ca644eb8e3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:15:39 +0000 Subject: [PATCH 105/166] wip --- src/py/reactpy/reactpy/core/layout.py | 3 +++ src/py/reactpy/reactpy/core/serve.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f3865c7e3..839766846 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -136,6 +136,9 @@ async def __aexit__(self, *exc: Any) -> None: def start_rendering(self) -> None: self._schedule_render_task(self._root_life_cycle_state_id) + def start_rendering_for_reconnect(self) -> None: + self._rendering_queue.put(self._root_life_cycle_state_id) + async def deliver(self, event: LayoutEventMessage) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 2b77abbdc..ceaec30ac 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -196,7 +196,7 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: layout.client_state = {} else: salt = client_state_msg["salt"] - layout.start_rendering() + layout.start_rendering_for_reconnect() await layout.render_until_queue_empty() layout.reconnecting.set_current(False) layout.client_state = {} From 0ca57529b18f0595da515e68dc31a90711cf0755 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:34:11 +0000 Subject: [PATCH 106/166] wip --- .../@reactpy/client/src/reactpy-client.ts | 10 ++++++++- src/py/reactpy/reactpy/core/layout.py | 22 ++++++++++++++----- src/py/reactpy/reactpy/core/types.py | 10 ++++++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index a528ca8a0..f90c26449 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -138,7 +138,8 @@ type ReconnectProps = { enum messageTypes { isReady = "is-ready", reconnectingCheck = "reconnecting-check", - clientState = "client-state" + clientState = "client-state", + stateUpdate = "state-update" }; export class SimpleReactPyClient @@ -181,6 +182,7 @@ export class SimpleReactPyClient this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) 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.reconnect() } @@ -200,6 +202,12 @@ export class SimpleReactPyClient }); } + updateClientState(stateVars: object): void { + if (!this.socket) + return; + this.updateStateVars(stateVars) + } + socketLoop(): void { if (!this.socket) return; diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 839766846..aa5af3e6d 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -18,7 +18,9 @@ from logging import getLogger from typing import ( Any, + Awaitable, Callable, + Coroutine, Generic, NamedTuple, NewType, @@ -51,6 +53,7 @@ Key, LayoutEventMessage, LayoutUpdateMessage, + StateUpdateMessage, VdomChild, VdomDict, VdomJson, @@ -139,7 +142,7 @@ def start_rendering(self) -> None: def start_rendering_for_reconnect(self) -> None: self._rendering_queue.put(self._root_life_cycle_state_id) - async def deliver(self, event: LayoutEventMessage) -> None: + async def deliver(self, event: LayoutEventMessage, send: Coroutine) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -150,10 +153,19 @@ async def deliver(self, event: LayoutEventMessage) -> None: if handler is not None: state_update_token = create_state_updates() try: - await handler.function(event["data"]) - except Exception: - logger.exception(f"Failed to execute event handler {handler}") - clear_state_updates(state_update_token) + try: + await handler.function(event["data"]) + except Exception: + logger.exception(f"Failed to execute event handler {handler}") + state_updates = get_state_updates() + if state_updates: + await send( + StateUpdateMessage( + type="state-update", state_vars=state_updates + ) + ) + finally: + clear_state_updates(state_update_token) else: logger.info( f"Ignored event - handler {event['target']!r} " diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 18e203eab..276e9a2c8 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -73,7 +73,7 @@ class LayoutType(Protocol[_Render_co, _Event_contra]): async def render(self) -> _Render_co: """Render an update to a view""" - async def deliver(self, event: _Event_contra) -> None: + async def deliver(self, event: _Event_contra, send: Callable) -> None: """Relay an event to its respective handler""" async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: @@ -216,6 +216,14 @@ class LayoutUpdateMessage(TypedDict): state_vars: dict[str, Any] +class StateUpdateMessage(TypedDict): + """A message describing an update to state variables""" + + type: Literal["state-update"] + """The type of message""" + state_vars: dict[str, Any] + + class ReconnectingCheckMessage(TypedDict): """A message describing an update to a layout""" From 8520c42794385510c52f1fa2db30c1c6ec76a37c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:35:18 +0000 Subject: [PATCH 107/166] wip --- src/py/reactpy/reactpy/core/serve.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index ceaec30ac..4e4eabc72 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -105,11 +105,12 @@ async def _single_incoming_loop( task_group: TaskGroup, layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], recv: RecvCoroutine, + send: SendCoroutine, ) -> None: 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(), send) class WebsocketServer: From 20dfece0ebedda37f293d8d82f6fcf5b7fe8f056 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:36:45 +0000 Subject: [PATCH 108/166] wip --- src/py/reactpy/reactpy/core/serve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 4e4eabc72..86759b6ec 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -69,7 +69,7 @@ async def serve_layout( try: async with create_task_group() as task_group: task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, task_group, layout, recv) + task_group.start_soon(_single_incoming_loop, task_group, layout, recv, send) except Stop: # nocov warn( "The Stop exception is deprecated and will be removed in a future version", From 8e1e4f2b334df98611daba81fd862f65737324fe Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:39:21 +0000 Subject: [PATCH 109/166] wip --- src/py/reactpy/reactpy/core/layout.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index aa5af3e6d..05ad316f8 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -161,7 +161,10 @@ async def deliver(self, event: LayoutEventMessage, send: Coroutine) -> None: if state_updates: await send( StateUpdateMessage( - type="state-update", state_vars=state_updates + type="state-update", + state_vars=self._state_recovery_serializer.serialize_state_vars( + state_updates + ), ) ) finally: From 972153e400c679da9ba0e40f63d579ada1beaa90 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:03:03 +0000 Subject: [PATCH 110/166] partial revert --- src/py/reactpy/reactpy/core/layout.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 05ad316f8..ce0a89dfa 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -102,6 +102,8 @@ def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer async def __aenter__(self) -> Layout: + self._hook_state_token = create_hook_state() + # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() @@ -192,7 +194,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) + 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 @@ -219,7 +221,7 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov f"{model_state_id!r} - component already unmounted" ) else: - return await self._create_layout_update(model_state) + return await self._create_layout_update(model_state, get_hook_state()) async def _concurrent_render(self) -> LayoutUpdateMessage: """Await the next available render. This will block until a component is updated""" @@ -230,9 +232,9 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: return update_task.result() async def _create_layout_update( - self, old_state: _ModelState + self, old_state: _ModelState, incoming_hook_state: list ) -> LayoutUpdateMessage: - hook_stack_token = create_hook_state() + hook_stack_token = create_hook_state(copy.copy(incoming_hook_state)) state_updates_token = create_state_updates() new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component @@ -602,7 +604,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state + component, schedule_render, reconnecting, client_state, {} ), ) From d796d5e0b82d13e374277d43480ff4407037ad52 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:03:57 +0000 Subject: [PATCH 111/166] wip --- src/py/reactpy/reactpy/core/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index ce0a89dfa..4cbd319ed 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -604,7 +604,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state, {} + component, schedule_render, reconnecting, client_state ), ) From 385a5210374898757013196d7476a0e3e096113e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:05:10 +0000 Subject: [PATCH 112/166] wip --- src/py/reactpy/reactpy/core/layout.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 4cbd319ed..d58f25dd7 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -44,6 +44,7 @@ clear_state_updates, create_hook_state, create_state_updates, + get_hook_state, get_state_updates, ) from reactpy.core.state_recovery import StateRecoverySerializer From 7fb6b1b487d90a2380bc1b9a83cbd10f4a3b2bb6 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:06:32 +0000 Subject: [PATCH 113/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 724211c0f..302e5f360 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -26,8 +26,8 @@ async def __call__(self, stop: Event) -> None: ... _state_updates = ContextVar("_state_updates") -def create_hook_state() -> Token[list]: - return _hook_state.set([]) +def create_hook_state(initial: list | None = None) -> Token[list]: + return _hook_state.set(initial or []) def create_state_updates() -> Token[dict]: From 5fcb3e982de82e0cf5e5897639342d05efc7e797 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:25:50 +0000 Subject: [PATCH 114/166] reverting --- .../reactpy/reactpy/core/_life_cycle_hook.py | 18 ++----- src/py/reactpy/reactpy/core/layout.py | 53 ++++++++++--------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 302e5f360..4482b5d7f 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -23,17 +23,12 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) _hook_state = ContextVar("_hook_state") -_state_updates = ContextVar("_state_updates") def create_hook_state(initial: list | None = None) -> Token[list]: return _hook_state.set(initial or []) -def create_state_updates() -> Token[dict]: - return _state_updates.set({}) - - def clear_hook_state(token: Token[list]) -> None: hook_stack = _hook_state.get() if hook_stack: @@ -41,18 +36,10 @@ def clear_hook_state(token: Token[list]) -> None: _hook_state.reset(token) -def clear_state_updates(token: Token[dict]) -> None: - _state_updates.reset(token) - - def get_hook_state() -> list[LifeCycleHook]: return _hook_state.get() -def get_state_updates() -> list[_CurrentState]: - return _state_updates.get() - - def get_current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" hook_stack = _hook_state.get() @@ -151,6 +138,7 @@ async def my_effect(stop_event): "component", "reconnecting", "client_state", + "_updated_states", ) component: ComponentType @@ -160,6 +148,7 @@ def __init__( schedule_render: Callable[[], None], reconnecting: Ref, client_state: dict[str, Any], + updated_states: dict[str, Any], ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -173,10 +162,11 @@ def __init__( self._render_access = Semaphore(1) # ensure only one render at a time self.reconnecting = reconnecting self.client_state = client_state or {} + self._updated_states = updated_states def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if updated_state.key: - get_state_updates()[updated_state.key] = updated_state.value + self._updated_states[updated_state.key] = updated_state.value def schedule_render(self) -> None: if self._scheduled_render: diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index d58f25dd7..c70b6e3b1 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -41,11 +41,8 @@ from reactpy.core._life_cycle_hook import ( LifeCycleHook, clear_hook_state, - clear_state_updates, create_hook_state, - create_state_updates, get_hook_state, - get_state_updates, ) from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( @@ -139,6 +136,8 @@ async def __aexit__(self, *exc: Any) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id + clear_hook_state(self._hook_state_token) + def start_rendering(self) -> None: self._schedule_render_task(self._root_life_cycle_state_id) @@ -154,24 +153,10 @@ async def deliver(self, event: LayoutEventMessage, send: Coroutine) -> None: handler = self._event_handlers.get(event["target"]) if handler is not None: - state_update_token = create_state_updates() try: - try: - await handler.function(event["data"]) - except Exception: - logger.exception(f"Failed to execute event handler {handler}") - state_updates = get_state_updates() - if state_updates: - await send( - StateUpdateMessage( - type="state-update", - state_vars=self._state_recovery_serializer.serialize_state_vars( - state_updates - ), - ) - ) - finally: - clear_state_updates(state_update_token) + await handler.function(event["data"]) + except Exception: + logger.exception(f"Failed to execute event handler {handler}") else: logger.info( f"Ignored event - handler {event['target']!r} " @@ -235,8 +220,7 @@ async def _concurrent_render(self) -> LayoutUpdateMessage: async def _create_layout_update( self, old_state: _ModelState, incoming_hook_state: list ) -> LayoutUpdateMessage: - hook_stack_token = create_hook_state(copy.copy(incoming_hook_state)) - state_updates_token = create_state_updates() + token = create_hook_state(copy.copy(incoming_hook_state)) new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component @@ -247,7 +231,9 @@ async def _create_layout_update( validate_vdom_json(new_state.model.current) state_vars = ( - self._state_recovery_serializer.serialize_state_vars(get_state_updates()) + self._state_recovery_serializer.serialize_state_vars( + new_state.life_cycle_state.hook._updated_states + ) if self._state_recovery_serializer else {} ) @@ -605,7 +591,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state + component, schedule_render, reconnecting, client_state, {} ), ) @@ -619,6 +605,9 @@ def _make_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: + updated_states = ( + parent.life_cycle_state or parent.parent_life_cycle_state + ).hook._updated_states return _ModelState( parent=parent, index=index, @@ -632,6 +621,7 @@ def _make_component_model_state( schedule_render, reconnecting, client_state, + updated_states, ), ) @@ -680,6 +670,9 @@ def _update_component_model_state( schedule_render, reconnecting, client_state, + ( + new_parent.life_cycle_state or new_parent.parent_life_cycle_state + ).hook._updated_states, ) ), ) @@ -698,6 +691,9 @@ def _make_element_model_state( patch_path=f"{parent.patch_path}/children/{index}", children_by_key={}, targets_by_event={}, + parent_life_cycle_state=( + parent.life_cycle_state or parent.parent_life_cycle_state + ), ) @@ -714,6 +710,9 @@ def _update_element_model_state( patch_path=old_model_state.patch_path, children_by_key={}, targets_by_event={}, + parent_life_cycle_state=( + new_parent.life_cycle_state or new_parent.parent_life_cycle_state + ), ) @@ -728,6 +727,7 @@ class _ModelState: "index", "key", "life_cycle_state", + "parent_life_cycle_state", "model", "patch_path", "targets_by_event", @@ -743,6 +743,7 @@ def __init__( children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], life_cycle_state: _LifeCycleState | None = None, + parent_life_cycle_state: _LifeCycleState | None = None, ): self.index = index """The index of the element amongst its siblings""" @@ -772,6 +773,8 @@ def __init__( self.life_cycle_state = life_cycle_state """The state for the element's component (if it has one)""" + self.parent_life_cycle_state = parent_life_cycle_state + @property def is_component_state(self) -> bool: return self.life_cycle_state is not None @@ -795,6 +798,7 @@ def _make_life_cycle_state( schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any], + updated_states: dict[str, Any], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( @@ -803,6 +807,7 @@ def _make_life_cycle_state( lambda: schedule_render(life_cycle_state_id), reconnecting=reconnecting, client_state=client_state, + updated_states=updated_states, ), component, ) From 652f70387bcc51056cb78ffce21176b754c740f5 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Sun, 3 Mar 2024 19:26:53 +0000 Subject: [PATCH 115/166] wip --- src/py/reactpy/reactpy/core/layout.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index c70b6e3b1..ba7df3e50 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -237,8 +237,7 @@ async def _create_layout_update( if self._state_recovery_serializer else {} ) - clear_hook_state(hook_stack_token) - clear_state_updates(state_updates_token) + clear_hook_state(token) return LayoutUpdateMessage( type="layout-update", path=new_state.patch_path, From 0cc4f728b9e74d913b77f889bef743c57a97b5d7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:16:09 +0000 Subject: [PATCH 116/166] perf tweaks --- src/py/reactpy/reactpy/core/state_recovery.py | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index ecbeb6cad..128eb0cf2 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -1,3 +1,4 @@ +import asyncio import base64 import datetime import hashlib @@ -8,6 +9,7 @@ from pathlib import Path from typing import Any, Callable from uuid import UUID +from more_itertools import chunked import orjson import pyotp @@ -137,7 +139,7 @@ def _get_otp_code(self, target_time: float) -> str: at = self._totp.at return f"{at(target_time)}{at(target_time - self._otp_mixer)}{at(target_time + self._otp_mixer)}" - def serialize_state_vars( + async def serialize_state_vars( self, state_vars: dict[str, Any] ) -> dict[str, tuple[str, str, str]]: if len(state_vars) > self._max_num_state_objects: @@ -146,30 +148,38 @@ def serialize_state_vars( ) return {} result = {} - for key, value in state_vars.items(): - result[key] = self._serialize(key, value) + for chunk in chunked(state_vars.items(), 20): + for key, value in chunk: + result[key] = self._serialize(key, value) + await asyncio.sleep(0) # relinquish CPU return result def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: if obj is None: return "0", "", "" - obj_type = type(obj) - if obj_type in (list, tuple): - if len(obj) != 0: - obj_type = type(obj[0]) - for t in obj_type.__mro__: - type_id = self._object_to_type_id.get(t) - if type_id: - break - else: - raise ValueError( - f"Objects of type {obj_type} was not part of serializable_types" - ) - result = self._serialize_object(obj) - if len(result) > self._max_object_length: - raise ValueError( - f"Serialized object {obj} is too long (length: {len(result)})" - ) + match obj: + case True: + result = "true" + case False: + result = "false" + case _: + obj_type = type(obj) + if obj_type in (list, tuple): + if len(obj) != 0: + obj_type = type(obj[0]) + for t in obj_type.__mro__: + type_id = self._object_to_type_id.get(t) + if type_id: + break + else: + raise ValueError( + f"Objects of type {obj_type} was not part of serializable_types" + ) + result = self._serialize_object(obj) + if len(result) > self._max_object_length: + raise ValueError( + f"Serialized object {obj} is too long (length: {len(result)})" + ) signature = self._sign_serialization(key, type_id, result) return ( type_id.decode("utf-8"), From 7f7cab6ba7507598c5b07d0df22886c173244c04 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:16:45 +0000 Subject: [PATCH 117/166] double state objects --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 128eb0cf2..49b7c5852 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -34,7 +34,7 @@ def __init__( otp_max_age: int = (48 * 60 * 60), # OTP code is actually three codes, in the past and future concatenated otp_mixer: float = (365 * 24 * 60 * 60 * 3), - max_num_state_objects: int = 256, + max_num_state_objects: int = 512, max_object_length: int = 40000, default_serializer: Callable[[Any], bytes] | None = None, deserializer_map: dict[type, Callable[[Any], Any]] | None = None, From 6be7d03002ae5b2c24dc325c32722556c7367867 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:22:36 +0000 Subject: [PATCH 118/166] wip --- pyproject.toml | 2 -- src/py/reactpy/pyproject.toml | 3 +++ src/py/reactpy/reactpy/core/layout.py | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d03233be7..775ab01a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,6 @@ dependencies = [ "semver >=2, <3", "twine", "pre-commit", - "pyotp", - "orjson", ] [tool.hatch.envs.default.scripts] diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 309248507..c16e5f065 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", + "pyotp", + "orjson", + "more-itertools", ] [project.optional-dependencies] all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index ba7df3e50..85e8952be 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -231,8 +231,11 @@ async def _create_layout_update( validate_vdom_json(new_state.model.current) state_vars = ( - self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._updated_states + + ( + await self._state_recovery_serializer.serialize_state_vars( + new_state.life_cycle_state.hook._updated_states + ) ) if self._state_recovery_serializer else {} From 45ca27aa743cc8fc80fb122067d2c4aabf42d1c5 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:24:20 +0000 Subject: [PATCH 119/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 49b7c5852..fe119f059 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -65,7 +65,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, str, int, float, bool, list, tuple, UUID, *serializable_types) + (None, bool, str, int, float, list, tuple, UUID, *serializable_types) ): idx_as_bytes = str(idx).encode("utf-8") self._object_to_type_id[typ] = idx_as_bytes @@ -160,8 +160,10 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: match obj: case True: result = "true" + type_id = 1 case False: result = "false" + type_id = 1 case _: obj_type = type(obj) if obj_type in (list, tuple): From 74a1946662ac9db9e67f8a4fbc7a97f9da2a6233 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:26:33 +0000 Subject: [PATCH 120/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index fe119f059..6fdf5a461 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -155,15 +155,14 @@ async def serialize_state_vars( return result def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: + type_id = "1" # bool if obj is None: return "0", "", "" match obj: case True: result = "true" - type_id = 1 case False: result = "false" - type_id = 1 case _: obj_type = type(obj) if obj_type in (list, tuple): From b089a35ed49158bcdacc25a2faa734d04b30a442 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:27:53 +0000 Subject: [PATCH 121/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 6fdf5a461..5ee29fe22 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -155,7 +155,7 @@ async def serialize_state_vars( return result def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: - type_id = "1" # bool + type_id = b"1" # bool if obj is None: return "0", "", "" match obj: From abce8da4fd2237f5919a7cd32010d496661d2222 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:29:07 +0000 Subject: [PATCH 122/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 5ee29fe22..7224201ea 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -160,9 +160,9 @@ def _serialize(self, key: str, obj: object) -> tuple[str, str, str]: return "0", "", "" match obj: case True: - result = "true" + result = b"true" case False: - result = "false" + result = b"false" case _: obj_type = type(obj) if obj_type in (list, tuple): From f1dfaf4979ee9aaef11bb1296bf996eac36f71b8 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:40:13 +0000 Subject: [PATCH 123/166] wip --- src/py/reactpy/reactpy/core/state_recovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/state_recovery.py b/src/py/reactpy/reactpy/core/state_recovery.py index 7224201ea..38be33786 100644 --- a/src/py/reactpy/reactpy/core/state_recovery.py +++ b/src/py/reactpy/reactpy/core/state_recovery.py @@ -9,10 +9,10 @@ from pathlib import Path from typing import Any, Callable from uuid import UUID -from more_itertools import chunked import orjson import pyotp +from more_itertools import chunked logger = getLogger(__name__) @@ -148,7 +148,7 @@ async def serialize_state_vars( ) return {} result = {} - for chunk in chunked(state_vars.items(), 20): + for chunk in chunked(state_vars.items(), 50): for key, value in chunk: result[key] = self._serialize(key, value) await asyncio.sleep(0) # relinquish CPU From 06fa110a8110a1871fa20ff5a51382c83644bd98 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:50:07 +0000 Subject: [PATCH 124/166] add priority --- src/py/reactpy/reactpy/core/component.py | 7 +++++-- src/py/reactpy/reactpy/core/layout.py | 26 +++++++++++++----------- src/py/reactpy/reactpy/core/types.py | 1 + 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py index f825aac71..aca5b8576 100644 --- a/src/py/reactpy/reactpy/core/component.py +++ b/src/py/reactpy/reactpy/core/component.py @@ -8,12 +8,13 @@ def component( - function: Callable[..., ComponentType | VdomDict | str | None] + function: Callable[..., ComponentType | VdomDict | str | None], priority: int = 0 ) -> Callable[..., Component]: """A decorator for defining a new component. Parameters: function: The component's :meth:`reactpy.core.proto.ComponentType.render` function. + priority: The rendering priority. Lower numbers are higher priority. """ sig = inspect.signature(function) @@ -26,7 +27,7 @@ def component( @wraps(function) def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: - return Component(function, key, args, kwargs, sig) + return Component(function, key, args, kwargs, sig, priority) return constructor @@ -43,12 +44,14 @@ def __init__( args: tuple[Any, ...], kwargs: dict[str, Any], sig: inspect.Signature, + priority: int = 0, ) -> None: self.key = key self.type = function self._args = args self._kwargs = kwargs self._sig = sig + self.priority = priority def render(self) -> ComponentType | VdomDict | str | None: return self.type(*self._args, **self._kwargs) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 85e8952be..be1e9db73 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -6,6 +6,7 @@ from asyncio import ( FIRST_COMPLETED, CancelledError, + PriorityQueue, Queue, Task, create_task, @@ -231,7 +232,6 @@ async def _create_layout_update( validate_vdom_json(new_state.model.current) state_vars = ( - ( await self._state_recovery_serializer.serialize_state_vars( new_state.life_cycle_state.hook._updated_states @@ -559,9 +559,11 @@ async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount.extend(model_state.children_by_key.values()) - def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: + def _schedule_render_task( + self, lcs_id: _LifeCycleStateId, priority: int = 0 + ) -> None: if not REACTPY_ASYNC_RENDERING.current: - self._rendering_queue.put(lcs_id) + self._rendering_queue.put(lcs_id, priority) return None try: model_state = self._model_states_by_life_cycle_state_id[lcs_id] @@ -603,7 +605,7 @@ def _make_component_model_state( index: int, key: Any, component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], + schedule_render: Callable[[_LifeCycleStateId, int], None], reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: @@ -652,7 +654,7 @@ def _update_component_model_state( new_parent: _ModelState, new_index: int, new_component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], + schedule_render: Callable[[_LifeCycleStateId, int], None], reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: @@ -797,7 +799,7 @@ def __repr__(self) -> str: # nocov def _make_life_cycle_state( component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], + schedule_render: Callable[[_LifeCycleStateId, int], None], reconnecting: bool, client_state: dict[str, Any], updated_states: dict[str, Any], @@ -806,7 +808,7 @@ def _make_life_cycle_state( return _LifeCycleState( life_cycle_state_id, LifeCycleHook( - lambda: schedule_render(life_cycle_state_id), + lambda: schedule_render(life_cycle_state_id, component.priority), reconnecting=reconnecting, client_state=client_state, updated_states=updated_states, @@ -849,21 +851,21 @@ class _LifeCycleState(NamedTuple): class _ThreadSafeQueue(Generic[_Type]): def __init__(self) -> None: self._loop = get_running_loop() - self._queue: Queue[_Type] = Queue() + self._queue: PriorityQueue[_Type] = PriorityQueue() self._pending: set[_Type] = set() - def put(self, value: _Type) -> None: + def put(self, value: _Type, priority: int = 0) -> 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._queue.put_nowait, (priority, value)) async def get(self) -> _Type: - value = await self._queue.get() + priority, value = await self._queue.get() self._pending.remove(value) return value async def get_nowait(self) -> _Type: - value = self._queue.get_nowait() + priority, value = self._queue.get_nowait() self._pending.remove(value) return value diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 276e9a2c8..439b5c418 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -57,6 +57,7 @@ class ComponentType(Protocol): This is used to see if two component instances share the same definition. """ + priority: int def render(self) -> VdomDict | ComponentType | str | None: """Render the component's view model.""" From a65231824c7c8ed92e49bd1158397fe540569b35 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:52:20 +0000 Subject: [PATCH 125/166] wip --- src/py/reactpy/reactpy/core/component.py | 33 +++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py index aca5b8576..487ab401a 100644 --- a/src/py/reactpy/reactpy/core/component.py +++ b/src/py/reactpy/reactpy/core/component.py @@ -7,29 +7,32 @@ from reactpy.core.types import ComponentType, VdomDict -def component( - function: Callable[..., ComponentType | VdomDict | str | None], priority: int = 0 -) -> Callable[..., Component]: +def component(priority: int = 0) -> Callable[..., Component]: """A decorator for defining a new component. Parameters: - function: The component's :meth:`reactpy.core.proto.ComponentType.render` function. priority: The rendering priority. Lower numbers are higher priority. """ - sig = inspect.signature(function) - if "key" in sig.parameters and sig.parameters["key"].kind in ( - inspect.Parameter.KEYWORD_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - msg = f"Component render function {function} uses reserved parameter 'key'" - raise TypeError(msg) + def _component( + function: Callable[..., ComponentType | VdomDict | str | None] + ) -> Callable[..., Component]: + sig = inspect.signature(function) - @wraps(function) - def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: - return Component(function, key, args, kwargs, sig, priority) + if "key" in sig.parameters and sig.parameters["key"].kind in ( + inspect.Parameter.KEYWORD_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + msg = f"Component render function {function} uses reserved parameter 'key'" + raise TypeError(msg) - return constructor + @wraps(function) + def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: + return Component(function, key, args, kwargs, sig, priority) + + return constructor + + return _component class Component: From 26c4ff9b261d36a724b122eabdf2947249a4314e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 19:00:49 +0000 Subject: [PATCH 126/166] wip --- src/py/reactpy/reactpy/core/component.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py index 487ab401a..4979faea3 100644 --- a/src/py/reactpy/reactpy/core/component.py +++ b/src/py/reactpy/reactpy/core/component.py @@ -7,7 +7,11 @@ from reactpy.core.types import ComponentType, VdomDict -def component(priority: int = 0) -> Callable[..., Component]: +def component( + function: Callable[..., ComponentType | VdomDict | str | None] | None = None, + *, + priority: int = 0, +) -> Callable[..., Component]: """A decorator for defining a new component. Parameters: @@ -32,6 +36,8 @@ def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: return constructor + if function: + return _component(function) return _component From e35457e927c76191e09a5fa40c1508beb3de7f37 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 19:01:47 +0000 Subject: [PATCH 127/166] wip --- src/py/reactpy/reactpy/core/component.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py index 4979faea3..4dff104c1 100644 --- a/src/py/reactpy/reactpy/core/component.py +++ b/src/py/reactpy/reactpy/core/component.py @@ -44,7 +44,16 @@ def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: class Component: """An object for rending component models.""" - __slots__ = "__weakref__", "_func", "_args", "_kwargs", "_sig", "key", "type" + __slots__ = ( + "__weakref__", + "_func", + "_args", + "_kwargs", + "_sig", + "key", + "type", + "priority", + ) def __init__( self, From 67548fe4e2e2009af1c25ae823c02fb17ebeee0e Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 19:09:28 +0000 Subject: [PATCH 128/166] wip --- src/py/reactpy/reactpy/core/hooks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 5dd1bef4d..72f7c2c4e 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -315,11 +315,13 @@ def __init__( value: _Type, key: Key | None, type: Context[_Type], + priority: int = -1, ) -> None: self.children = children self.key = key self.type = type self.value = value + self.priority = priority def render(self) -> VdomDict: get_current_hook().set_context_provider(self) From 8dbbee25508fe1fbe9c624f0ca462790558d02a1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:36:55 +0000 Subject: [PATCH 129/166] cache previous state --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 8 +++++++- src/py/reactpy/reactpy/core/layout.py | 13 ++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 4482b5d7f..61dbe3441 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -139,6 +139,7 @@ async def my_effect(stop_event): "reconnecting", "client_state", "_updated_states", + "_previous_states", ) component: ComponentType @@ -149,6 +150,7 @@ def __init__( reconnecting: Ref, client_state: dict[str, Any], updated_states: dict[str, Any], + previous_states: dict[str, Any], ) -> None: self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} self._schedule_render_callback = schedule_render @@ -163,9 +165,13 @@ def __init__( self.reconnecting = reconnecting self.client_state = client_state or {} self._updated_states = updated_states + self._previous_states = previous_states def add_state_update(self, updated_state: _CurrentState | Ref) -> None: - if updated_state.key: + if ( + updated_state.key + and self._previous_states[updated_state.key] != updated_state.value + ): self._updated_states[updated_state.key] = updated_state.value def schedule_render(self) -> None: diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index be1e9db73..84ca99875 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -609,9 +609,7 @@ def _make_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: - updated_states = ( - parent.life_cycle_state or parent.parent_life_cycle_state - ).hook._updated_states + hook = (parent.life_cycle_state or parent.parent_life_cycle_state).hook return _ModelState( parent=parent, index=index, @@ -625,7 +623,8 @@ def _make_component_model_state( schedule_render, reconnecting, client_state, - updated_states, + hook._updated_states, + hook._previous_states, ), ) @@ -658,6 +657,7 @@ def _update_component_model_state( reconnecting: bool, client_state: dict[str, Any], ) -> _ModelState: + hook = (new_parent.life_cycle_state or new_parent.parent_life_cycle_state).hook return _ModelState( parent=new_parent, index=new_index, @@ -674,9 +674,8 @@ def _update_component_model_state( schedule_render, reconnecting, client_state, - ( - new_parent.life_cycle_state or new_parent.parent_life_cycle_state - ).hook._updated_states, + hook._updated_states, + hook._previous_states, ) ), ) From dbcc09a7f6a1d2832cfbc1ffa8ad027794d2d80a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:40:23 +0000 Subject: [PATCH 130/166] wip --- src/py/reactpy/reactpy/core/layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 84ca99875..291f5a198 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -595,7 +595,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state, {} + component, schedule_render, reconnecting, client_state, {}, {} ), ) @@ -802,6 +802,7 @@ def _make_life_cycle_state( reconnecting: bool, client_state: dict[str, Any], updated_states: dict[str, Any], + previous_states: dict[str, Any], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) return _LifeCycleState( @@ -811,6 +812,7 @@ def _make_life_cycle_state( reconnecting=reconnecting, client_state=client_state, updated_states=updated_states, + previous_states=previous_states, ), component, ) From d014012b0bb4c1653e93d1fd5f9fcc28f109258a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:42:07 +0000 Subject: [PATCH 131/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 61dbe3441..29c914784 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -170,7 +170,7 @@ def __init__( def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if ( updated_state.key - and self._previous_states[updated_state.key] != updated_state.value + and self._previous_states.get(updated_state.key) is not updated_state.value ): self._updated_states[updated_state.key] = updated_state.value From d737dd9a045e570905a1de9e48fa6b934d8a84d5 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:46:00 +0000 Subject: [PATCH 132/166] wip --- src/py/reactpy/reactpy/core/layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 291f5a198..d47bc7279 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -79,6 +79,7 @@ class Layout: "_state_recovery_serializer", "_state_var_lock", "_hook_state_token", + "_previous_states", ) if not hasattr(abc.ABC, "__weakref__"): # nocov @@ -96,6 +97,7 @@ def __init__( self.reconnecting = Ref(False) self._state_recovery_serializer = None self.client_state = {} + self._previous_states = {} def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer @@ -595,7 +597,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state, {}, {} + component, schedule_render, reconnecting, client_state, {}, self._previous_states ), ) From e609382e5a7cdd7d11b15c81038dc8bb6884fd81 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:47:06 +0000 Subject: [PATCH 133/166] wip --- src/py/reactpy/reactpy/core/layout.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index d47bc7279..a22c5c686 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -112,7 +112,7 @@ async def __aenter__(self) -> Layout: self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state( - self.root, self._schedule_render_task, self.reconnecting, self.client_state + self.root, self._schedule_render_task, self.reconnecting, self.client_state, self._previous_states ) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id @@ -587,6 +587,7 @@ def _new_root_model_state( schedule_render: Callable[[_LifeCycleStateId], None], reconnecting: bool, client_state: dict[str, Any], + previous_states: dict[str, Any], ) -> _ModelState: return _ModelState( parent=None, @@ -597,7 +598,7 @@ def _new_root_model_state( children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state( - component, schedule_render, reconnecting, client_state, {}, self._previous_states + component, schedule_render, reconnecting, client_state, {}, previous_states ), ) From df946ebb2fd68c6a627b62bf45da9c889deed64d Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:50:48 +0000 Subject: [PATCH 134/166] wip --- src/py/reactpy/reactpy/core/layout.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index a22c5c686..2d8aee912 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -112,7 +112,11 @@ async def __aenter__(self) -> Layout: self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state( - self.root, self._schedule_render_task, self.reconnecting, self.client_state, self._previous_states + self.root, + self._schedule_render_task, + self.reconnecting, + self.client_state, + self._previous_states, ) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id @@ -242,6 +246,7 @@ async def _create_layout_update( if self._state_recovery_serializer else {} ) + new_state.life_cycle_state.hook._updated_states.clear() clear_hook_state(token) return LayoutUpdateMessage( type="layout-update", From 595316f67425b861a5d5720fadb14e7af5317b34 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 20:57:37 +0000 Subject: [PATCH 135/166] wip --- src/py/reactpy/reactpy/core/layout.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 2d8aee912..a26bc4a58 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -237,16 +237,18 @@ async def _create_layout_update( if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) + updated_states = new_state.life_cycle_state.hook._updated_states state_vars = ( ( await self._state_recovery_serializer.serialize_state_vars( - new_state.life_cycle_state.hook._updated_states + updated_states ) ) if self._state_recovery_serializer else {} ) - new_state.life_cycle_state.hook._updated_states.clear() + self._previous_states.update(updated_states) + updated_states.clear() clear_hook_state(token) return LayoutUpdateMessage( type="layout-update", From 3f18e250be0f0830c7170f4f733c355dac9d5ad2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 21:02:28 +0000 Subject: [PATCH 136/166] wip --- src/py/reactpy/reactpy/core/_life_cycle_hook.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index 29c914784..c0e3dfc6f 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -170,7 +170,10 @@ def __init__( def add_state_update(self, updated_state: _CurrentState | Ref) -> None: if ( updated_state.key - and self._previous_states.get(updated_state.key) is not updated_state.value + and self._previous_states.get( + updated_state.key, "__missing_lifecycle_key_value__" + ) + is not updated_state.value ): self._updated_states[updated_state.key] = updated_state.value From 880bcf894367f786ae86e48725b9f2d198a8c7ba Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 21:09:18 +0000 Subject: [PATCH 137/166] perf --- src/py/reactpy/reactpy/core/layout.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index a26bc4a58..6c9c10bf8 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -45,6 +45,8 @@ create_hook_state, get_hook_state, ) +from reactpy.core.component import Component +from reactpy.core.hooks import _ContextProvider from reactpy.core.state_recovery import StateRecoverySerializer from reactpy.core.types import ( ComponentType, @@ -90,9 +92,10 @@ def __init__( root: ComponentType, ) -> None: super().__init__() - if not isinstance(root, ComponentType): - msg = f"Expected a ComponentType, not {type(root)!r}." - raise TypeError(msg) + # slow + # if not isinstance(root, ComponentType): + # msg = f"Expected a ComponentType, not {type(root)!r}." + # raise TypeError(msg) self.root = root self.reconnecting = Ref(False) self._state_recovery_serializer = None @@ -239,11 +242,7 @@ async def _create_layout_update( updated_states = new_state.life_cycle_state.hook._updated_states state_vars = ( - ( - await self._state_recovery_serializer.serialize_state_vars( - updated_states - ) - ) + (await self._state_recovery_serializer.serialize_state_vars(updated_states)) if self._state_recovery_serializer else {} ) @@ -889,7 +888,7 @@ def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]: elif isinstance(child, dict): child_type = _DICT_TYPE key = child.get("key") - elif isinstance(child, ComponentType): + elif isinstance(child, (Component, _ContextProvider)): child_type = _COMPONENT_TYPE key = child.key else: From 508255f4d38da7025c8c0a808ae8bbccca6c5d86 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 22:55:12 +0000 Subject: [PATCH 138/166] wip --- src/py/reactpy/reactpy/core/layout.py | 10 ++++++++-- src/py/reactpy/reactpy/core/serve.py | 12 +++++++++--- src/py/reactpy/reactpy/core/types.py | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 6c9c10bf8..8db467e30 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -106,6 +106,9 @@ def set_recovery_serializer(self, serializer: StateRecoverySerializer) -> None: self._state_recovery_serializer = serializer async def __aenter__(self) -> Layout: + return await self.start() + + async def start(self) -> Layout: self._hook_state_token = create_hook_state() # create attributes here to avoid access before entering context manager @@ -128,6 +131,9 @@ async def __aenter__(self) -> Layout: return self async def __aexit__(self, *exc: Any) -> None: + return await self.finish() + + async def finish(self) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] @@ -154,7 +160,7 @@ def start_rendering(self) -> None: def start_rendering_for_reconnect(self) -> None: self._rendering_queue.put(self._root_life_cycle_state_id) - async def deliver(self, event: LayoutEventMessage, send: Coroutine) -> None: + async def deliver(self, event: LayoutEventMessage) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -168,7 +174,7 @@ async def deliver(self, event: LayoutEventMessage, send: Coroutine) -> None: except Exception: logger.exception(f"Failed to execute event handler {handler}") else: - logger.info( + logger.warning( f"Ignored event - handler {event['target']!r} " "does not exist or its component unmounted" ) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 86759b6ec..b7832b565 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -110,7 +110,7 @@ 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(), send) + task_group.start_soon(layout.deliver, await recv()) class WebsocketServer: @@ -197,8 +197,14 @@ async def _do_state_rebuild_for_reconnection(self, layout: Layout) -> str: layout.client_state = {} else: salt = client_state_msg["salt"] - layout.start_rendering_for_reconnect() - await layout.render_until_queue_empty() + try: + layout.start_rendering_for_reconnect() + await layout.render_until_queue_empty() + except StateRecoveryFailureError: + logger.warning("Client state non-recoverable. Starting fresh") + await layout.finish() + await layout.start() + layout.start_rendering() layout.reconnecting.set_current(False) layout.client_state = {} return salt diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 439b5c418..a4be74f61 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -74,7 +74,7 @@ class LayoutType(Protocol[_Render_co, _Event_contra]): async def render(self) -> _Render_co: """Render an update to a view""" - async def deliver(self, event: _Event_contra, send: Callable) -> None: + async def deliver(self, event: _Event_contra) -> None: """Relay an event to its respective handler""" async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: From 350ef04a3644969372dbacd4e2ddc96a36d5c719 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:14:32 +0000 Subject: [PATCH 139/166] add retries --- .../@reactpy/client/src/reactpy-client.ts | 113 +++++++++++------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index f90c26449..997ee49c7 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -159,6 +159,8 @@ export class SimpleReactPyClient private isReconnecting: boolean; private isReady: boolean; private salt: string; + private shouldReconnect: boolean; + private lastReconnectAttempt: number; constructor(props: SimpleReactPyClientProps) { super(); @@ -178,6 +180,8 @@ export class SimpleReactPyClient this.isReconnecting = false; this.isReady = false this.salt = ""; + this.shouldReconnect = false; + this.lastReconnectAttempt = 0; this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) this.onMessage(messageTypes.isReady, (msg) => { this.isReady = true; this.salt = msg.salt; }); @@ -236,27 +240,55 @@ export class SimpleReactPyClient } } - reconnect(onOpen?: () => void): void { - this.socket = createWebSocket({ - readyPromise: this.ready, - url: this.urls.stream, - onOpen: onOpen, - onClose: () => { - this.isReconnecting = true; - this.isReady = false; - if (this.socketLoopIntervalId) - clearInterval(this.socketLoopIntervalId); - if (this.idleCheckIntervalId) - clearInterval(this.idleCheckIntervalId); - if (!this.sleeping) { - this.reconnect(onOpen); - } - }, - onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, - ...this.reconnectOptions, - }); - this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, 30); - this.idleCheckIntervalId = window.setInterval(() => { this.idleTimeoutCheck() }, 10000); + reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30): void { + if (this.shouldReconnect) { + // already reconnecting + return; + } + this.shouldReconnect = true; + + let lastSuccess = 0; + window.setTimeout(() => { + + const intervalJitter = this.reconnectOptions?.intervalJitter || 1.1; + const backoffRate = this.reconnectOptions?.backoffRate || 1.1; + const maxInterval = this.reconnectOptions?.maxInterval || 20000; + const maxRetries = this.reconnectOptions?.maxRetries || retriesRemaining; + + if (maxRetries > retriesRemaining) + retriesRemaining = maxRetries; + + this.socket = createWebSocket({ + readyPromise: this.ready, + url: this.urls.stream, + onOpen: () => { + lastSuccess = Date.now(); + if (onOpen) + onOpen(); + }, + onClose: () => { + this.isReconnecting = true; + this.isReady = false; + if (this.socketLoopIntervalId) + clearInterval(this.socketLoopIntervalId); + if (this.idleCheckIntervalId) + clearInterval(this.idleCheckIntervalId); + if (!this.sleeping) { + const thisInterval = addJitter(interval, intervalJitter); + logger.log( + `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, + ); + interval = nextInterval(interval, backoffRate, maxInterval); + this.reconnect(onOpen, interval, retriesRemaining - 1); + } + }, + onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, + ...this.reconnectOptions, + }); + this.socketLoopIntervalId = window.setInterval(() => { this.socketLoop() }, 30); + this.idleCheckIntervalId = window.setInterval(() => { this.idleTimeoutCheck() }, 10000); + + }, interval) } ensureConnected(): void { @@ -341,10 +373,9 @@ function createWebSocket( socket.current.onclose = () => { if (!everConnected) { logger.log("failed to connect"); - return; + } else { + logger.log("client disconnected"); } - - logger.log("client disconnected"); if (props.onClose) { props.onClose(); } @@ -368,23 +399,23 @@ function createWebSocket( return socket; } -// function nextInterval( -// currentInterval: number, -// backoffRate: number, -// maxInterval: number, -// ): number { -// return Math.min( -// (currentInterval * -// // increase interval by backoff rate -// backoffRate), -// // don't exceed max interval -// maxInterval, -// ); -// } - -// function addJitter(interval: number, jitter: number): number { -// return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -// } +function nextInterval( + currentInterval: number, + backoffRate: number, + maxInterval: number, +): number { + return Math.min( + (currentInterval * + // increase interval by backoff rate + backoffRate), + // don't exceed max interval + maxInterval, + ); +} + +function addJitter(interval: number, jitter: number): number { + return interval + (Math.random() * jitter * interval * 2 - jitter * interval); +} function rtrim(text: string, trim: string): string { return text.replace(new RegExp(`${trim}+$`), ""); From 33d5490ec0ef2f9d160f7ec89283dbff1c3cf059 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:16:03 +0000 Subject: [PATCH 140/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 997ee49c7..8bf5284db 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -267,6 +267,10 @@ export class SimpleReactPyClient onOpen(); }, onClose: () => { + // reset retry interval + if (Date.now() - lastSuccess > maxInterval * 2) { + interval = 750; + } this.isReconnecting = true; this.isReady = false; if (this.socketLoopIntervalId) From a5bfad6f6c093121270b215549cc50e6b76e1fcd Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:16:29 +0000 Subject: [PATCH 141/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 8bf5284db..05827917d 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -160,7 +160,6 @@ export class SimpleReactPyClient private isReady: boolean; private salt: string; private shouldReconnect: boolean; - private lastReconnectAttempt: number; constructor(props: SimpleReactPyClientProps) { super(); From 6d43e99b915c4c1fe072a15c7f6c9ae1501ca6b8 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:16:53 +0000 Subject: [PATCH 142/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 05827917d..0eab60449 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -180,7 +180,6 @@ export class SimpleReactPyClient this.isReady = false this.salt = ""; this.shouldReconnect = false; - this.lastReconnectAttempt = 0; this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) this.onMessage(messageTypes.isReady, (msg) => { this.isReady = true; this.salt = msg.salt; }); From 6d743ea952a0561de1df2ef61d8859684a1c23e9 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:21:29 +0000 Subject: [PATCH 143/166] efwf --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 0eab60449..c2846fc35 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -239,6 +239,11 @@ export class SimpleReactPyClient } reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30): void { + const intervalJitter = this.reconnectOptions?.intervalJitter || 1.1; + const backoffRate = this.reconnectOptions?.backoffRate || 1.1; + const maxInterval = this.reconnectOptions?.maxInterval || 20000; + const maxRetries = this.reconnectOptions?.maxRetries || retriesRemaining; + if (this.shouldReconnect) { // already reconnecting return; @@ -248,11 +253,6 @@ export class SimpleReactPyClient let lastSuccess = 0; window.setTimeout(() => { - const intervalJitter = this.reconnectOptions?.intervalJitter || 1.1; - const backoffRate = this.reconnectOptions?.backoffRate || 1.1; - const maxInterval = this.reconnectOptions?.maxInterval || 20000; - const maxRetries = this.reconnectOptions?.maxRetries || retriesRemaining; - if (maxRetries > retriesRemaining) retriesRemaining = maxRetries; @@ -269,6 +269,7 @@ export class SimpleReactPyClient if (Date.now() - lastSuccess > maxInterval * 2) { interval = 750; } + this.shouldReconnect = false; this.isReconnecting = true; this.isReady = false; if (this.socketLoopIntervalId) From c9791d32790bdbac2090889fa438f3b9c56b3f5c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:29:25 +0000 Subject: [PATCH 144/166] add connectoin timeout --- .../packages/@reactpy/client/src/reactpy-client.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index c2846fc35..1defc479a 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -95,6 +95,7 @@ export type SimpleReactPyClientProps = { reconnectOptions?: ReconnectProps; forceRerender?: boolean; idleDisconnectTimeSeconds?: number; + connectionTimeout?: number; }; /** @@ -160,6 +161,7 @@ export class SimpleReactPyClient private isReady: boolean; private salt: string; private shouldReconnect: boolean; + private connectionTimeout: number; constructor(props: SimpleReactPyClientProps) { super(); @@ -173,6 +175,7 @@ export class SimpleReactPyClient ); this.idleDisconnectTimeMillis = (props.idleDisconnectTimeSeconds || 240) * 1000; this.forceRerender = props.forceRerender !== undefined ? props.forceRerender : false; + this.connectionTimeout = props.connectionTimeout || 5000; this.lastMessageTime = Date.now() this.reconnectOptions = props.reconnectOptions this.sleeping = false; @@ -339,6 +342,7 @@ function createWebSocket( props: { url: string; readyPromise: Promise; + connectionTimeout: number; onOpen?: () => void; onMessage: (message: MessageEvent) => void; onClose?: () => void; @@ -363,7 +367,17 @@ function createWebSocket( return; } socket.current = new WebSocket(props.url); + const connectionTimeout = props.connectionTimeout; // Timeout in milliseconds + + const timeoutId = setTimeout(() => { + // If the socket is still not open, close it to trigger the onerror event + if (socket.current && socket.current.readyState !== WebSocket.OPEN) { + socket.current.close(); + console.error('Connection attempt timed out'); + } + }, connectionTimeout); socket.current.onopen = () => { + clearTimeout(timeoutId); everConnected = true; logger.log("client connected"); // interval = startInterval; From 02eb80d4c69033604f5566639059ec5d379bf79a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:30:51 +0000 Subject: [PATCH 145/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 1defc479a..69443990e 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -260,6 +260,7 @@ export class SimpleReactPyClient retriesRemaining = maxRetries; this.socket = createWebSocket({ + connectionTimeout: this.connectionTimeout, readyPromise: this.ready, url: this.urls.stream, onOpen: () => { From 8a44cf223b789edbc3a2502ff0df3620188ce9cd Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:09:23 +0000 Subject: [PATCH 146/166] wip --- .../packages/@reactpy/client/src/reactpy-client.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 69443990e..f8d827e7f 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -242,11 +242,16 @@ export class SimpleReactPyClient } reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30): void { - const intervalJitter = this.reconnectOptions?.intervalJitter || 1.1; - const backoffRate = this.reconnectOptions?.backoffRate || 1.1; + 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 || retriesRemaining; + if (retriesRemaining <= 0) { + this.shouldReconnect = false; + return + } + if (this.shouldReconnect) { // already reconnecting return; @@ -285,8 +290,7 @@ export class SimpleReactPyClient logger.log( `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, ); - interval = nextInterval(interval, backoffRate, maxInterval); - this.reconnect(onOpen, interval, retriesRemaining - 1); + this.reconnect(onOpen, nextInterval(interval, backoffRate, maxInterval), retriesRemaining - 1); } }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, From fd5f0bdd6c78878b5392defee9c2882126fbf79c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:15:57 +0000 Subject: [PATCH 147/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index f8d827e7f..02bd59307 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -241,12 +241,13 @@ export class SimpleReactPyClient } } - reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30): void { + reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30, lastSuccess: 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 || retriesRemaining; + if (retriesRemaining <= 0) { this.shouldReconnect = false; return @@ -256,9 +257,9 @@ export class SimpleReactPyClient // already reconnecting return; } + lastSuccess = lastSuccess || Date.now(); this.shouldReconnect = true; - let lastSuccess = 0; window.setTimeout(() => { if (maxRetries > retriesRemaining) @@ -277,6 +278,7 @@ export class SimpleReactPyClient // reset retry interval if (Date.now() - lastSuccess > maxInterval * 2) { interval = 750; + lastSuccess = Date.now() } this.shouldReconnect = false; this.isReconnecting = true; @@ -286,11 +288,11 @@ export class SimpleReactPyClient if (this.idleCheckIntervalId) clearInterval(this.idleCheckIntervalId); if (!this.sleeping) { - const thisInterval = addJitter(interval, intervalJitter); + const thisInterval = nextInterval(addJitter(interval, intervalJitter), backoffRate, maxInterval); logger.log( `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, ); - this.reconnect(onOpen, nextInterval(interval, backoffRate, maxInterval), retriesRemaining - 1); + this.reconnect(onOpen, thisInterval, retriesRemaining - 1, lastSuccess); } }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, From a127f1a49a9c1aae8b9bc54a4587efe3956a2599 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:19:29 +0000 Subject: [PATCH 148/166] wip --- .../packages/@reactpy/client/src/reactpy-client.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 02bd59307..e7a506ec9 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -241,7 +241,7 @@ export class SimpleReactPyClient } } - reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30, lastSuccess: number = 0): void { + reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30, lastAttempt: number = 0): void { const intervalJitter = this.reconnectOptions?.intervalJitter || 0.5; const backoffRate = this.reconnectOptions?.backoffRate || 1.2; const maxInterval = this.reconnectOptions?.maxInterval || 20000; @@ -257,7 +257,7 @@ export class SimpleReactPyClient // already reconnecting return; } - lastSuccess = lastSuccess || Date.now(); + lastAttempt = lastAttempt || Date.now(); this.shouldReconnect = true; window.setTimeout(() => { @@ -270,16 +270,16 @@ export class SimpleReactPyClient readyPromise: this.ready, url: this.urls.stream, onOpen: () => { - lastSuccess = Date.now(); + lastAttempt = Date.now(); if (onOpen) onOpen(); }, onClose: () => { // reset retry interval - if (Date.now() - lastSuccess > maxInterval * 2) { + if (Date.now() - lastAttempt > maxInterval * 2) { interval = 750; - lastSuccess = Date.now() } + lastAttempt = Date.now() this.shouldReconnect = false; this.isReconnecting = true; this.isReady = false; @@ -292,7 +292,7 @@ export class SimpleReactPyClient logger.log( `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, ); - this.reconnect(onOpen, thisInterval, retriesRemaining - 1, lastSuccess); + this.reconnect(onOpen, thisInterval, retriesRemaining - 1, lastAttempt); } }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, From 7fe56d6a65af29e0d325bbfee7ea7023c4aa63c2 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:27:44 +0000 Subject: [PATCH 149/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index e7a506ec9..4ad9f6e9a 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -190,6 +190,15 @@ export class SimpleReactPyClient this.onMessage(messageTypes.stateUpdate, (msg) => { this.updateClientState(msg.state_vars) }); this.reconnect() + + const reconnectOnUserAction = (ev: any) => { + if (!this.isReady && !this.isReconnecting) { + this.reconnect(); + } + } + + window.addEventListener('mousemove', reconnectOnUserAction); + window.addEventListener('scroll', reconnectOnUserAction); } indicateReconnect(): void { From 6d7933b3fe6570afc3c343a947f9925044aaf166 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:33:54 +0000 Subject: [PATCH 150/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 4ad9f6e9a..b889b29ba 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -250,7 +250,7 @@ export class SimpleReactPyClient } } - reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 30, lastAttempt: number = 0): void { + reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 2, lastAttempt: number = 0): void { const intervalJitter = this.reconnectOptions?.intervalJitter || 0.5; const backoffRate = this.reconnectOptions?.backoffRate || 1.2; const maxInterval = this.reconnectOptions?.maxInterval || 20000; @@ -258,6 +258,7 @@ export class SimpleReactPyClient if (retriesRemaining <= 0) { + logger.warn("Giving up on reconnecting (hit retry limit)"); this.shouldReconnect = false; return } From d6ebb48435d3e35f56525563038eb0d91ba5b227 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:37:00 +0000 Subject: [PATCH 151/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index b889b29ba..60875d113 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -300,7 +300,7 @@ export class SimpleReactPyClient if (!this.sleeping) { const thisInterval = nextInterval(addJitter(interval, intervalJitter), backoffRate, maxInterval); logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, + `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds... (${retriesRemaining} retries remaining)`, ); this.reconnect(onOpen, thisInterval, retriesRemaining - 1, lastAttempt); } From 95d7e826812be059e1e154bc0a0253ca477aa5d3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:37:48 +0000 Subject: [PATCH 152/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 60875d113..ba00790db 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -260,6 +260,7 @@ export class SimpleReactPyClient if (retriesRemaining <= 0) { logger.warn("Giving up on reconnecting (hit retry limit)"); this.shouldReconnect = false; + this.isReconnecting = false; return } From f92e363683023ecadc3308027f8f963b9870e953 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:40:36 +0000 Subject: [PATCH 153/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index ba00790db..9c9a7483c 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -300,10 +300,11 @@ export class SimpleReactPyClient clearInterval(this.idleCheckIntervalId); if (!this.sleeping) { const thisInterval = nextInterval(addJitter(interval, intervalJitter), backoffRate, maxInterval); + const newRetriesRemaining = retriesRemaining - 1; logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds... (${retriesRemaining} retries remaining)`, + `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds... (${newRetriesRemaining} retries remaining)`, ); - this.reconnect(onOpen, thisInterval, retriesRemaining - 1, lastAttempt); + this.reconnect(onOpen, thisInterval, newRetriesRemaining, lastAttempt); } }, onMessage: async ({ data }) => { this.lastMessageTime = Date.now(); this.handleIncoming(JSON.parse(data)) }, From 18bf283ced939bde3ecbddea1eaffa0e199b76f3 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:43:04 +0000 Subject: [PATCH 154/166] reset retries remaining on retry interval reset --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 9c9a7483c..c47622652 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -289,6 +289,7 @@ export class SimpleReactPyClient // reset retry interval if (Date.now() - lastAttempt > maxInterval * 2) { interval = 750; + retriesRemaining = maxRetries; } lastAttempt = Date.now() this.shouldReconnect = false; From 72ebebf704a04837fac295fb36961dc8d4cdcbef Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:46:51 +0000 Subject: [PATCH 155/166] wip --- .../packages/@reactpy/client/src/reactpy-client.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index c47622652..554889a1b 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -250,14 +250,14 @@ export class SimpleReactPyClient } } - reconnect(onOpen?: () => void, interval: number = 750, retriesRemaining: number = 2, lastAttempt: number = 0): void { + 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 || retriesRemaining; + const maxRetries = this.reconnectOptions?.maxRetries || connectionAttemptsRemaining; - if (retriesRemaining <= 0) { + if (connectionAttemptsRemaining <= 0) { logger.warn("Giving up on reconnecting (hit retry limit)"); this.shouldReconnect = false; this.isReconnecting = false; @@ -273,8 +273,8 @@ export class SimpleReactPyClient window.setTimeout(() => { - if (maxRetries > retriesRemaining) - retriesRemaining = maxRetries; + if (maxRetries > connectionAttemptsRemaining) + connectionAttemptsRemaining = maxRetries; this.socket = createWebSocket({ connectionTimeout: this.connectionTimeout, @@ -289,7 +289,7 @@ export class SimpleReactPyClient // reset retry interval if (Date.now() - lastAttempt > maxInterval * 2) { interval = 750; - retriesRemaining = maxRetries; + connectionAttemptsRemaining = maxRetries; } lastAttempt = Date.now() this.shouldReconnect = false; @@ -301,7 +301,7 @@ export class SimpleReactPyClient clearInterval(this.idleCheckIntervalId); if (!this.sleeping) { const thisInterval = nextInterval(addJitter(interval, intervalJitter), backoffRate, maxInterval); - const newRetriesRemaining = retriesRemaining - 1; + const newRetriesRemaining = connectionAttemptsRemaining - 1; logger.log( `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds... (${newRetriesRemaining} retries remaining)`, ); From f8e3978ca4c30f3f19ac7bc3d439085b75270fb4 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 00:55:21 +0000 Subject: [PATCH 156/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 554889a1b..cd8215522 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -254,7 +254,7 @@ export class SimpleReactPyClient 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 || connectionAttemptsRemaining; + const maxRetries = this.reconnectOptions?.maxRetries || 20; if (connectionAttemptsRemaining <= 0) { @@ -273,8 +273,8 @@ export class SimpleReactPyClient window.setTimeout(() => { - if (maxRetries > connectionAttemptsRemaining) - connectionAttemptsRemaining = maxRetries; + if (maxRetries < connectionAttemptsRemaining) + connectionAttemptsRemaining = maxRetries; this.socket = createWebSocket({ connectionTimeout: this.connectionTimeout, From e1ff31d34b1c1c989f80eeea80161d9229e71910 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:02:23 +0000 Subject: [PATCH 157/166] adjust jitter --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index cd8215522..443ce7e05 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -451,7 +451,7 @@ function nextInterval( } function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); + return interval + (Math.random() * jitter * interval); } function rtrim(text: string, trim: string): string { From 41037845ad875ff7161f1b9e49eb05a9ebf96ba6 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:18:35 +0000 Subject: [PATCH 158/166] wip --- .../@reactpy/client/src/reactpy-client.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 443ce7e05..d04bc5495 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -134,6 +134,8 @@ type ReconnectProps = { maxRetries?: number; backoffRate?: number; intervalJitter?: number; + reconnectingCallback?: Function; + reconnectedCallback?: Function; }; enum messageTypes { @@ -162,6 +164,8 @@ export class SimpleReactPyClient private salt: string; private shouldReconnect: boolean; private connectionTimeout: number; + private reconnectingCallback: Function; + private reconnectedCallback: Function; constructor(props: SimpleReactPyClientProps) { super(); @@ -183,6 +187,8 @@ export class SimpleReactPyClient this.isReady = false this.salt = ""; this.shouldReconnect = false; + this.reconnectingCallback = props.reconnectOptions?.reconnectingCallback || this.showReconnectingGrayout; + this.reconnectedCallback = props.reconnectOptions?.reconnectedCallback || this.hideReconnectingGrayout; this.onMessage(messageTypes.reconnectingCheck, () => { this.indicateReconnect() }) this.onMessage(messageTypes.isReady, (msg) => { this.isReady = true; this.salt = msg.salt; }); @@ -201,6 +207,58 @@ export class SimpleReactPyClient window.addEventListener('scroll', reconnectOnUserAction); } + showReconnectingGrayout() { + // Create the overlay + const overlay = document.createElement('div'); + overlay.id = 'reactpy-reconnect-overlay'; + + Object.assign(overlay.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', // Transparent gray + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: '100000' // Ensure it's above other content + }); + + // Create the pipe symbol + const pipeSymbol = document.createElement('div'); + pipeSymbol.textContent = '|'; // Set the pipe symbol + + // Style the pipe symbol + Object.assign(pipeSymbol.style, { + fontSize: '24px', + color: '#FFF', // White color for visibility + textAlign: 'center' // Ensure text is centered + }); + + // Append the pipeSymbol to the overlay + overlay.appendChild(pipeSymbol); + + // Append the overlay to the body + document.body.appendChild(overlay); + + // Create and start the spin animation + let angle = 0; + function spin() { + angle = (angle + 2) % 360; // Adjust rotation speed as needed + pipeSymbol.style.transform = `rotate(${angle}deg)`; + requestAnimationFrame(spin); + } + spin(); + } + + hideReconnectingGrayout() { + const overlay = document.getElementById('reactpy-reconnect-overlay'); + if (overlay && overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + } + indicateReconnect(): void { const isReconnecting = this.isReconnecting ? "yes" : "no"; this.sendMessage({ "type": messageTypes.reconnectingCheck, "value": isReconnecting }, true) @@ -272,6 +330,8 @@ export class SimpleReactPyClient this.shouldReconnect = true; window.setTimeout(() => { + if (this.reconnectingCallback) + this.reconnectingCallback() if (maxRetries < connectionAttemptsRemaining) connectionAttemptsRemaining = maxRetries; @@ -282,6 +342,8 @@ export class SimpleReactPyClient url: this.urls.stream, onOpen: () => { lastAttempt = Date.now(); + if (this.reconnectedCallback) + this.reconnectedCallback() if (onOpen) onOpen(); }, From eb76afc987692a5fe782df6d1bffc610c780974a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:23:24 +0000 Subject: [PATCH 159/166] wip --- .../@reactpy/client/src/reactpy-client.ts | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index d04bc5495..0f742f15c 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -166,6 +166,7 @@ export class SimpleReactPyClient private connectionTimeout: number; private reconnectingCallback: Function; private reconnectedCallback: Function; + private showingGrayout: boolean; constructor(props: SimpleReactPyClientProps) { super(); @@ -187,6 +188,7 @@ export class SimpleReactPyClient this.isReady = false this.salt = ""; this.shouldReconnect = false; + this.showingGrayout = false; this.reconnectingCallback = props.reconnectOptions?.reconnectingCallback || this.showReconnectingGrayout; this.reconnectedCallback = props.reconnectOptions?.reconnectedCallback || this.hideReconnectingGrayout; @@ -208,33 +210,53 @@ export class SimpleReactPyClient } showReconnectingGrayout() { + if (this.showingGrayout) + return // Create the overlay const overlay = document.createElement('div'); overlay.id = 'reactpy-reconnect-overlay'; - Object.assign(overlay.style, { - position: 'fixed', - top: '0', - left: '0', - width: '100%', - height: '100%', - backgroundColor: 'rgba(0,0,0,0.5)', // Transparent gray - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - zIndex: '100000' // Ensure it's above other content - }); - // Create the pipe symbol + const pipeContainer = document.createElement('div'); const pipeSymbol = document.createElement('div'); pipeSymbol.textContent = '|'; // Set the pipe symbol + // Style the overlay + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + `; + + // Style the pipe container (if needed) + pipeContainer.style.cssText = ` + display: flex; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + `; + // Style the pipe symbol - Object.assign(pipeSymbol.style, { - fontSize: '24px', - color: '#FFF', // White color for visibility - textAlign: 'center' // Ensure text is centered - }); + pipeSymbol.style.cssText = ` + font-size: 24px; + color: #FFF; + display: inline-block; + width: 100%; + text-align: center; + transform-origin: center; + `; + + pipeContainer.appendChild(pipeSymbol); + overlay.appendChild(pipeContainer); + document.body.appendChild(overlay); // Append the pipeSymbol to the overlay overlay.appendChild(pipeSymbol); @@ -253,6 +275,7 @@ export class SimpleReactPyClient } hideReconnectingGrayout() { + this.showingGrayout = false; const overlay = document.getElementById('reactpy-reconnect-overlay'); if (overlay && overlay.parentNode) { overlay.parentNode.removeChild(overlay); From 25c6de4c803c0dcd7c2bf86b3d229fc0e07a3ca1 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:25:08 +0000 Subject: [PATCH 160/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 0f742f15c..1f8ac2255 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -237,7 +237,7 @@ export class SimpleReactPyClient // Style the pipe container (if needed) pipeContainer.style.cssText = ` - display: flex; + display: block; justify-content: center; align-items: center; width: 40px; From d1ac36fe8c638d1e934c316de6bda359d98c3f6c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:26:21 +0000 Subject: [PATCH 161/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 1f8ac2255..37529873e 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -258,12 +258,6 @@ export class SimpleReactPyClient overlay.appendChild(pipeContainer); document.body.appendChild(overlay); - // Append the pipeSymbol to the overlay - overlay.appendChild(pipeSymbol); - - // Append the overlay to the body - document.body.appendChild(overlay); - // Create and start the spin animation let angle = 0; function spin() { From 29c24195afb7f2c3829cd89ecc0395a5a6a4e57c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:28:42 +0000 Subject: [PATCH 162/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 37529873e..605837426 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -212,6 +212,7 @@ export class SimpleReactPyClient showReconnectingGrayout() { if (this.showingGrayout) return + this.showingGrayout = true; // Create the overlay const overlay = document.createElement('div'); overlay.id = 'reactpy-reconnect-overlay'; From ac1291817cc11d24a3a5c2af79f5e6a5e95aa836 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:30:48 +0000 Subject: [PATCH 163/166] wip --- src/js/packages/@reactpy/client/src/reactpy-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 605837426..95d211266 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -238,7 +238,7 @@ export class SimpleReactPyClient // Style the pipe container (if needed) pipeContainer.style.cssText = ` - display: block; + display: flex; justify-content: center; align-items: center; width: 40px; From 5d9efbdc2b2d648d77d2ac15022445ead196850c Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:37:50 +0000 Subject: [PATCH 164/166] wip --- .../@reactpy/client/src/reactpy-client.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 95d211266..695546532 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -166,7 +166,7 @@ export class SimpleReactPyClient private connectionTimeout: number; private reconnectingCallback: Function; private reconnectedCallback: Function; - private showingGrayout: boolean; + private didReconnectingCallback: boolean; constructor(props: SimpleReactPyClientProps) { super(); @@ -188,7 +188,7 @@ export class SimpleReactPyClient this.isReady = false this.salt = ""; this.shouldReconnect = false; - this.showingGrayout = false; + this.didReconnectingCallback = false; this.reconnectingCallback = props.reconnectOptions?.reconnectingCallback || this.showReconnectingGrayout; this.reconnectedCallback = props.reconnectOptions?.reconnectedCallback || this.hideReconnectingGrayout; @@ -210,9 +210,6 @@ export class SimpleReactPyClient } showReconnectingGrayout() { - if (this.showingGrayout) - return - this.showingGrayout = true; // Create the overlay const overlay = document.createElement('div'); overlay.id = 'reactpy-reconnect-overlay'; @@ -251,6 +248,7 @@ export class SimpleReactPyClient color: #FFF; display: inline-block; width: 100%; + height: 100%; text-align: center; transform-origin: center; `; @@ -270,7 +268,6 @@ export class SimpleReactPyClient } hideReconnectingGrayout() { - this.showingGrayout = false; const overlay = document.getElementById('reactpy-reconnect-overlay'); if (overlay && overlay.parentNode) { overlay.parentNode.removeChild(overlay); @@ -348,8 +345,10 @@ export class SimpleReactPyClient this.shouldReconnect = true; window.setTimeout(() => { - if (this.reconnectingCallback) - this.reconnectingCallback() + if (!this.didReconnectingCallback && this.reconnectingCallback) { + this.didReconnectingCallback = true; + this.reconnectingCallback(); + } if (maxRetries < connectionAttemptsRemaining) connectionAttemptsRemaining = maxRetries; @@ -360,8 +359,10 @@ export class SimpleReactPyClient url: this.urls.stream, onOpen: () => { lastAttempt = Date.now(); - if (this.reconnectedCallback) - this.reconnectedCallback() + if (this.reconnectedCallback) { + this.reconnectedCallback(); + this.didReconnectingCallback = false; + } if (onOpen) onOpen(); }, From d556eb5311c5afd2aabd6816d61edc325808d11a Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:54:07 +0000 Subject: [PATCH 165/166] cleanup --- .../@reactpy/client/src/reactpy-client.ts | 46 ++++--------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 695546532..c5018e9a5 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -42,11 +42,13 @@ export abstract class BaseReactPyClient implements ReactPyClient { protected readonly ready: Promise; private resolveReady: (value: undefined) => void; protected stateVars: object; + protected debugMessages: boolean; constructor() { this.resolveReady = () => { }; this.ready = new Promise((resolve) => (this.resolveReady = resolve)); this.stateVars = {}; + this.debugMessages = false; } onMessage(type: string, handler: (message: any) => void): () => void { @@ -77,7 +79,9 @@ export abstract class BaseReactPyClient implements ReactPyClient { return; } - logger.log("Got message", message); + if (this.debugMessages) { + logger.log("Got message", message); + } const messageHandlers: ((m: any) => void)[] | undefined = this.handlers[message.type]; @@ -93,9 +97,9 @@ export abstract class BaseReactPyClient implements ReactPyClient { export type SimpleReactPyClientProps = { serverLocation?: LocationProps; reconnectOptions?: ReconnectProps; - forceRerender?: boolean; idleDisconnectTimeSeconds?: number; connectionTimeout?: number; + debugMessages?: boolean; }; /** @@ -153,8 +157,6 @@ export class SimpleReactPyClient private idleDisconnectTimeMillis: number; private lastMessageTime: number; private reconnectOptions: ReconnectProps | undefined; - // @ts-ignore - private forceRerender: boolean; private messageQueue: any[] = []; private socketLoopIntervalId?: number | null; private idleCheckIntervalId?: number | null; @@ -179,10 +181,10 @@ export class SimpleReactPyClient }, ); this.idleDisconnectTimeMillis = (props.idleDisconnectTimeSeconds || 240) * 1000; - this.forceRerender = props.forceRerender !== undefined ? props.forceRerender : false; this.connectionTimeout = props.connectionTimeout || 5000; this.lastMessageTime = Date.now() this.reconnectOptions = props.reconnectOptions + this.debugMessages = props.debugMessages || false; this.sleeping = false; this.isReconnecting = false; this.isReady = false @@ -210,16 +212,13 @@ export class SimpleReactPyClient } showReconnectingGrayout() { - // Create the overlay const overlay = document.createElement('div'); overlay.id = 'reactpy-reconnect-overlay'; - // Create the pipe symbol const pipeContainer = document.createElement('div'); const pipeSymbol = document.createElement('div'); pipeSymbol.textContent = '|'; // Set the pipe symbol - // Style the overlay overlay.style.cssText = ` position: fixed; top: 0; @@ -233,7 +232,6 @@ export class SimpleReactPyClient z-index: 1000; `; - // Style the pipe container (if needed) pipeContainer.style.cssText = ` display: flex; justify-content: center; @@ -242,7 +240,6 @@ export class SimpleReactPyClient height: 40px; `; - // Style the pipe symbol pipeSymbol.style.cssText = ` font-size: 24px; color: #FFF; @@ -306,7 +303,9 @@ export class SimpleReactPyClient transmitMessage(message: any): void { if (this.socket && this.socket.current) { - logger.log("Sending message", message); + if (this.debugMessages) { + logger.log("Sending message", message); + } this.socket.current.send(JSON.stringify(message)); } } @@ -449,16 +448,6 @@ function createWebSocket( onClose?: () => void; }, ) { - // const { - // maxInterval = 60000, - // maxRetries = 50, - // backoffRate = 1.1, - // intervalJitter = 0.1, - // } = props; - - // const startInterval = 750; - // let retries = 0; - // let interval = startInterval; const closed = false; let everConnected = false; const socket: { current?: WebSocket } = {}; @@ -471,7 +460,6 @@ function createWebSocket( const connectionTimeout = props.connectionTimeout; // Timeout in milliseconds const timeoutId = setTimeout(() => { - // If the socket is still not open, close it to trigger the onerror event if (socket.current && socket.current.readyState !== WebSocket.OPEN) { socket.current.close(); console.error('Connection attempt timed out'); @@ -481,8 +469,6 @@ function createWebSocket( clearTimeout(timeoutId); everConnected = true; logger.log("client connected"); - // interval = startInterval; - // retries = 0; if (props.onOpen) { props.onOpen(); } @@ -497,18 +483,6 @@ function createWebSocket( if (props.onClose) { props.onClose(); } - - // if (retries >= maxRetries) { - // return; - // } - - // const thisInterval = addJitter(interval, intervalJitter); - // logger.log( - // `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - // ); - // setTimeout(connect, thisInterval); - // interval = nextInterval(interval, backoffRate, maxInterval); - // retries++; }; }; From 49609084c3a899ca146399eb3ca135215e07800b Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:58:06 +0000 Subject: [PATCH 166/166] merge fixes --- src/py/reactpy/reactpy/core/component.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py index 661213694..9d4955546 100644 --- a/src/py/reactpy/reactpy/core/component.py +++ b/src/py/reactpy/reactpy/core/component.py @@ -2,7 +2,7 @@ import inspect from functools import wraps -from typing import Any, Callable, TypeVar, ParamSpec +from typing import Any, Callable, ParamSpec, TypeVar from reactpy.core.types import ComponentType, VdomDict @@ -11,7 +11,9 @@ def component( - function: Callable[P, T], + function: Callable[P, T] | None = None, + *, + priority: int = 0, ) -> Callable[P, Component]: """A decorator for defining a new component. @@ -19,9 +21,7 @@ def component( priority: The rendering priority. Lower numbers are higher priority. """ - def _component( - function: Callable[..., ComponentType | VdomDict | str | None] - ) -> Callable[..., Component]: + def _component(function: Callable[P, T]) -> Callable[P, Component]: sig = inspect.signature(function) if "key" in sig.parameters and sig.parameters["key"].kind in ( @@ -32,7 +32,9 @@ def _component( raise TypeError(msg) @wraps(function) - def constructor(*args: P.args, key: Any | None = None, **kwargs: P.kwargs) -> Component: + def constructor( + *args: P.args, key: Any | None = None, **kwargs: P.kwargs + ) -> Component: return Component(function, key, args, kwargs, sig, priority) return constructor