diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..3364448d924 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -25,6 +25,13 @@ 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"; + +// 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"]; @@ -677,23 +684,49 @@ 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; + let errorEmitted = false; + 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_EVENT, { + message: errorMsg, + substate: substate, + error_type: ERROR_TYPE_DISPATCH_MISSING, + }); + errorEmitted = true; + 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); + // Emit error to backend if it wasn't already emitted + if (!errorEmitted) { + socket.current.emit(CLIENT_ERROR_EVENT, { + message: error.message || String(error), + error_type: ERROR_TYPE_STATE_UPDATE, + }); + } + // 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/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. 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)