From 8b0230d275a55c011ba77af9642fe7c724659191 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Thu, 12 Feb 2026 16:08:17 +0100 Subject: [PATCH 1/4] feat: improve error handling for unprocessable state deltas --- reflex/.templates/web/utils/state.js | 56 ++++++++++++++++++++-------- reflex/app.py | 33 ++++++++++++++++ reflex/state.py | 17 ++++++++- 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..558ac860edb 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -677,23 +677,47 @@ export const connect = async ( // On each received message, queue the updates and events. socket.current.on("event", async (update) => { - for (const substate in update.delta) { - dispatch[substate](update.delta[substate]); - // handle events waiting for `is_hydrated` - if ( - substate === state_name && - update.delta[substate]?.is_hydrated_rx_state_ - ) { - queueEvents(on_hydrated_queue, socket, false, navigate, params); - on_hydrated_queue.length = 0; + try { + for (const substate in update.delta) { + if (typeof dispatch[substate] !== "function") { + const errorMsg = `Cannot process state update: dispatch function for substate "${substate}" is not available. This usually indicates a mismatch between frontend and backend state definitions. Please rebuild the frontend or check that api_url is correct.`; + console.error(errorMsg); + // Emit error back to backend so it appears in terminal logs + socket.current.emit("client_error", { + message: errorMsg, + substate: substate, + error_type: "dispatch_function_missing", + }); + throw new Error(errorMsg); + } + dispatch[substate](update.delta[substate]); + // handle events waiting for `is_hydrated` + if ( + substate === state_name && + update.delta[substate]?.is_hydrated_rx_state_ + ) { + queueEvents(on_hydrated_queue, socket, false, navigate, params); + on_hydrated_queue.length = 0; + } } - } - applyClientStorageDelta(client_storage, update.delta); - if (update.final !== null) { - event_processing = !update.final; - } - if (update.events) { - queueEvents(update.events, socket, false, navigate, params); + applyClientStorageDelta(client_storage, update.delta); + if (update.final !== null) { + event_processing = !update.final; + } + if (update.events) { + queueEvents(update.events, socket, false, navigate, params); + } + } catch (error) { + console.error("Error processing state update:", error); + // If error wasn't already emitted above, emit it + if (error.message && !error.message.includes("dispatch function")) { + socket.current.emit("client_error", { + message: error.message, + error_type: "state_update_processing_error", + }); + } + // Stop processing further updates to prevent cascading errors + event_processing = false; } }); socket.current.on("reload", async (event) => { diff --git a/reflex/app.py b/reflex/app.py index 4ff412ef863..2dad86ae8ea 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -2145,6 +2145,12 @@ async def emit_update(self, update: StateUpdate, token: str) -> None: f"Attempting to send delta to disconnected client {token!r}" ) return + # Log the substates being sent for debugging mismatches + if update.delta: + substates = list(update.delta.keys()) + console.debug( + f"Emitting state update for substates: {substates} to client {token!r}" + ) # Creating a task prevents the update from being blocked behind other coroutines. await asyncio.create_task( self.emit(str(constants.SocketEvent.EVENT), update, to=socket_record.sid), @@ -2246,6 +2252,33 @@ async def on_ping(self, sid: str): # Emit the test event. await self.emit(str(constants.SocketEvent.PING), "pong", to=sid) + async def on_client_error(self, sid: str, data: dict[str, Any]): + """Handle errors reported by the frontend. + + This allows frontend errors (especially state update processing errors) + to be visible in backend logs, improving debuggability. + + Args: + sid: The Socket.IO session id. + data: The error data from the client. + """ + error_type = data.get("error_type", "unknown") + message = data.get("message", "No error message provided") + substate = data.get("substate") + + # Log the error with details + if error_type == "dispatch_function_missing": + console.error( + f"[Frontend Error - SID: {sid}] State update failed: " + f"Substate '{substate}' dispatcher not found. " + f"This indicates a frontend/backend mismatch. " + f"Ensure 'reflex export' or rebuild was run after state changes." + ) + else: + console.error( + f"[Frontend Error - SID: {sid}] {error_type}: {message}" + ) + async def link_token_to_sid(self, sid: str, token: str): """Link a token to a session id. diff --git a/reflex/state.py b/reflex/state.py index 89698cfa186..11e8482442e 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2798,7 +2798,22 @@ def create(cls, *children, **props) -> Component: frozen=True, ) class StateUpdate: - """A state update sent to the frontend.""" + """A state update sent to the frontend. + + The delta contains state changes keyed by substate name. Each substate must + have a corresponding dispatch function registered in the frontend. If the + frontend receives a delta with an unknown substate, it will: + + 1. Log a detailed error to the browser console + 2. Emit a 'client_error' event back to the backend + 3. Stop processing further updates to prevent cascading errors + + This typically indicates a mismatch between frontend and backend state + definitions, which can occur if: + - The frontend was not rebuilt after state changes + - The api_url points to a different backend version + - Manual state manipulation created invalid substate keys + """ # The state delta. delta: Delta = dataclasses.field(default_factory=dict) From af94a164941688242c5bf20eefa17444ac567365 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Thu, 12 Feb 2026 16:34:41 +0100 Subject: [PATCH 2/4] refactor: extract client_error event name to constant --- reflex/.templates/web/utils/state.js | 7 +++++-- reflex/constants/event.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 558ac860edb..056c1ace38b 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -25,6 +25,9 @@ import { uploadFiles } from "$/utils/helpers/upload"; // Endpoint URLs. const EVENTURL = env.EVENT; +// Socket event names (must match reflex/constants/event.py SocketEvent) +const CLIENT_ERROR_EVENT = "client_error"; + // These hostnames indicate that the backend and frontend are reachable via the same domain. const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"]; @@ -683,7 +686,7 @@ export const connect = async ( const errorMsg = `Cannot process state update: dispatch function for substate "${substate}" is not available. This usually indicates a mismatch between frontend and backend state definitions. Please rebuild the frontend or check that api_url is correct.`; console.error(errorMsg); // Emit error back to backend so it appears in terminal logs - socket.current.emit("client_error", { + socket.current.emit(CLIENT_ERROR_EVENT, { message: errorMsg, substate: substate, error_type: "dispatch_function_missing", @@ -711,7 +714,7 @@ export const connect = async ( console.error("Error processing state update:", error); // If error wasn't already emitted above, emit it if (error.message && !error.message.includes("dispatch function")) { - socket.current.emit("client_error", { + socket.current.emit(CLIENT_ERROR_EVENT, { message: error.message, error_type: "state_update_processing_error", }); diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 6a0f71ec161..91273c74a99 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -49,6 +49,7 @@ class SocketEvent(SimpleNamespace): PING = "ping" EVENT = "event" + CLIENT_ERROR = "client_error" def __str__(self) -> str: """Get the string representation of the event name. From 37b8fdc5e4618609b1981490569240bf6d4e4f81 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Thu, 12 Feb 2026 16:36:37 +0100 Subject: [PATCH 3/4] refactor: extract error type strings to constants --- reflex/.templates/web/utils/state.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 056c1ace38b..752fd68b7c0 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -28,6 +28,10 @@ const EVENTURL = env.EVENT; // Socket event names (must match reflex/constants/event.py SocketEvent) const CLIENT_ERROR_EVENT = "client_error"; +// Client error types (must match backend error handling in app.py) +const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing"; +const ERROR_TYPE_STATE_UPDATE = "state_update_processing_error"; + // These hostnames indicate that the backend and frontend are reachable via the same domain. const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"]; @@ -689,7 +693,7 @@ export const connect = async ( socket.current.emit(CLIENT_ERROR_EVENT, { message: errorMsg, substate: substate, - error_type: "dispatch_function_missing", + error_type: ERROR_TYPE_DISPATCH_MISSING, }); throw new Error(errorMsg); } @@ -716,7 +720,7 @@ export const connect = async ( if (error.message && !error.message.includes("dispatch function")) { socket.current.emit(CLIENT_ERROR_EVENT, { message: error.message, - error_type: "state_update_processing_error", + error_type: ERROR_TYPE_STATE_UPDATE, }); } // Stop processing further updates to prevent cascading errors From 93c397c5aa816f3fa93a80ca057ca60e37ccf950 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Thu, 12 Feb 2026 16:39:18 +0100 Subject: [PATCH 4/4] refactor: use flag instead of fragile string matching for error detection --- reflex/.templates/web/utils/state.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 752fd68b7c0..3364448d924 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -684,6 +684,7 @@ export const connect = async ( // On each received message, queue the updates and events. socket.current.on("event", async (update) => { + let errorEmitted = false; try { for (const substate in update.delta) { if (typeof dispatch[substate] !== "function") { @@ -695,6 +696,7 @@ export const connect = async ( substate: substate, error_type: ERROR_TYPE_DISPATCH_MISSING, }); + errorEmitted = true; throw new Error(errorMsg); } dispatch[substate](update.delta[substate]); @@ -716,10 +718,10 @@ export const connect = async ( } } catch (error) { console.error("Error processing state update:", error); - // If error wasn't already emitted above, emit it - if (error.message && !error.message.includes("dispatch function")) { + // Emit error to backend if it wasn't already emitted + if (!errorEmitted) { socket.current.emit(CLIENT_ERROR_EVENT, { - message: error.message, + message: error.message || String(error), error_type: ERROR_TYPE_STATE_UPDATE, }); }