Skip to content

Commit 02b92bb

Browse files
Detect a frontend/backend state schema mismatch at connect and prompt a reload (#7553)
The compiled frontend and the backend can come from different builds: a redeploy with a page kept open, mixed backend versions behind a load balancer, or an api_url pointing at another backend. The #7435 fallback skips deltas for unknown substates, but the skew stays hidden from users and a changed var in a known state is never detected. BaseState._to_frontend_schema() hashes what the compiled frontend depends on over the whole tree: each state's full name, its frontend var names and types (base and computed), and its event handler names. Defaults and values are left out. It is lru-cached like _to_schema() and cleared with it, so a state registered later invalidates the cached root. The compiler writes the hash into the context module as stateSchema; the frontend sends it in the socket.io CONNECT auth under state_schema on the first connect and every reconnect; EventNamespace.on_connect compares it with the backend's hash, emits one state_mismatch event to that client, logs one warning, and still hydrates. A missing hash (old frontend) is ignored, and an old backend ignores the new auth key. On a mismatch in prod the page reloads once, guarded by the existing reflex_last_reloaded_on_error cooldown, so mixed versions cannot loop; a mismatch inside the cooldown shows a banner with a reload button. In dev only a console warning is logged, since a backend hot reload can reconnect before Vite serves the rebuilt bundle. Closes #7552. Claude-Session: https://claude.ai/code/session_01EYJS7NPEz1gNkXZdqQDTA3
1 parent 0d295cd commit 02b92bb

15 files changed

Lines changed: 897 additions & 25 deletions

File tree

‎docs/utility_methods/exception_handlers.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,23 @@ The `frontend` errors are coming from the JavaScript side of the application, wh
1515

1616
### Frontend and backend state mismatches
1717

18-
If a backend update names a substate that the compiled frontend does not recognize, Reflex skips that substate. Updates to known substates and subsequent events continue to work. Reflex warns in the browser console and reports each unknown substate to `frontend_exception_handler` once per connection.
18+
The compiled frontend and the backend can come from different builds of the app: a backend deploy changes the state while a browser keeps the old page, two backend versions run behind one load balancer during a rolling deploy, or `api_url` points at a backend that did not serve the frontend. The frontend then holds states, vars and event handlers the backend does not know, or the other way around.
1919

20-
This can happen when a backend deploy adds state while a browser still uses an older frontend bundle. Refresh the page to load the matching bundle. If the warning persists, rebuild the frontend and check that `api_url` points to the intended backend. Reflex only skips unknown substates; it does not reconcile other differences, such as a renamed state or a changed var.
20+
Reflex detects this when the frontend connects. The compiled frontend carries a hash of the state tree it was built for (the state names, their frontend vars with types, and their event handler names) and sends it with every socket connect and reconnect. The backend computes the same hash over its own state tree and compares the two. Defaults and values are not part of the hash, so changing a default value does not count as a mismatch.
21+
22+
On a mismatch:
23+
24+
- In production, the page reloads once to fetch the new frontend. The reload shares the `REFLEX_AUTO_RELOAD_COOLDOWN` (default 10 seconds) with the automatic reload on frontend errors, so a page never reloads in a loop. When a reload already happened inside the cooldown (the reload landed on the same mismatch, for example a cached page or mixed backend versions) the page shows a banner saying a new version of the app is available, with a reload button, and keeps working as well as it can.
25+
- In development, the frontend only logs a warning in the browser console. A backend hot reload can reconnect before the frontend dev server serves the rebuilt page, which gives a short-lived mismatch that resolves on its own.
26+
27+
The backend logs one warning per mismatched connection.
28+
29+
Independently of that check, if a backend update names a substate that the compiled frontend does not recognize, Reflex skips that substate. Updates to known substates and subsequent events continue to work. Reflex warns in the browser console and reports each unknown substate to `frontend_exception_handler` once per connection. If the warning persists after a reload, rebuild the frontend and check that `api_url` points to the intended backend. Reflex only skips unknown substates; it does not reconcile other differences, such as a renamed state or a changed var.
30+
31+
```md alert warning
32+
# Load balancers need sticky sessions.
33+
When several backend versions run behind one load balancer, each reconnect can land on a different version, and the frontend cannot match all of them. Pin each client to one backend (sticky sessions) or to one deployment during a rollout; the framework cannot work around this.
34+
```
2135

2236
## Register an Exception Handler
2337

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Detect a frontend built for another version of the app when it connects to the backend. The compiled frontend now carries a hash of the state tree (state names, frontend vars and event handler names) and sends it on every socket connect. On a mismatch, a production page reloads once (sharing the `REFLEX_AUTO_RELOAD_COOLDOWN` with the reload on frontend errors) and otherwise shows a "new version available" banner with a reload button; in dev mode it only logs a console warning. Load balancers serving several backend versions still need sticky sessions.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The compiled context module exports `stateSchema`, a hash of the state tree the frontend was built for, and the frontend sends it in the socket.io CONNECT `auth` payload under the `state_schema` key. The backend answers a mismatch with the new `state_mismatch` socket event; in production, the frontend then reloads once per cooldown window or shows a "new version available" banner, while in development it only warns in the console.

‎packages/reflex-base/src/reflex_base/.templates/web/utils/state.js‎

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const EVENTURL = env.EVENT;
2222

2323
// Socket event names (must match reflex_base/constants/event.py SocketEvent)
2424
const CLIENT_ERROR_EVENT = "client_error";
25+
const STATE_MISMATCH_EVENT = "state_mismatch";
26+
27+
// sessionStorage key with the time of the last automatic reload (must match
28+
// LAST_RELOADED_KEY in reflex/state.py: the reload on a frontend error and the
29+
// reload on a state mismatch share one cooldown, so neither can loop).
30+
const LAST_RELOADED_KEY = "reflex_last_reloaded_on_error";
31+
32+
// The id of the banner offering a reload when one already happened recently.
33+
const NEW_VERSION_BANNER_ID = "reflex-new-version-banner";
2534

2635
// Client error types (must match reflex_base/constants/event.py ClientErrorType)
2736
const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing";
@@ -792,6 +801,74 @@ export const processEvent = async (socket, navigate, params) => {
792801
}
793802
};
794803

804+
/**
805+
* Reload the page for a new version of the app, restarting the reload cooldown.
806+
*/
807+
const reloadForNewVersion = () => {
808+
window.sessionStorage.setItem(LAST_RELOADED_KEY, Date.now().toString());
809+
window.location.reload();
810+
};
811+
812+
/**
813+
* Show a banner saying a new version is available, with a reload button.
814+
*
815+
* Plain DOM rather than a React component: it must show whatever the app
816+
* renders, and stay up while the page keeps working as well as it can.
817+
*/
818+
const showNewVersionBanner = () => {
819+
if (document.getElementById(NEW_VERSION_BANNER_ID)) {
820+
return;
821+
}
822+
const banner = document.createElement("div");
823+
banner.id = NEW_VERSION_BANNER_ID;
824+
banner.setAttribute("role", "alert");
825+
banner.style.cssText =
826+
"position:fixed;top:0;left:0;right:0;z-index:2147483647;display:flex;" +
827+
"justify-content:center;align-items:center;gap:1rem;padding:0.5rem 1rem;" +
828+
"background:#1c2024;color:#fff;font:1rem system-ui,sans-serif;";
829+
banner.append("A new version of this app is available.");
830+
const button = document.createElement("button");
831+
button.textContent = "Reload";
832+
button.style.cssText =
833+
"padding:0.25rem 0.75rem;border:1px solid #fff;border-radius:4px;" +
834+
"background:transparent;color:inherit;font:inherit;cursor:pointer;";
835+
button.onclick = reloadForNewVersion;
836+
banner.append(button);
837+
document.body.append(banner);
838+
};
839+
840+
/**
841+
* Handle the backend reporting that this bundle was compiled for another
842+
* state tree than the one it runs (sent on connect, see EventNamespace.on_connect).
843+
*
844+
* In prod the page reloads once per cooldown window. A mismatch inside the
845+
* window means the reload landed on the same skew (a cached bundle, or several
846+
* backend versions behind one load balancer), so a banner with a reload button
847+
* shows instead and the page never loops. In dev a backend hot reload can
848+
* reconnect before Vite serves the rebuilt bundle, so it only warns.
849+
* @param info The backend's state schema hash and the reload cooldown in ms.
850+
*/
851+
const handleStateMismatch = (info) => {
852+
const message =
853+
"This page was built for another version of the app than the backend it " +
854+
`connected to (state schema ${app.stateSchema}, backend ${info.backend}).`;
855+
if (app.isDevMode) {
856+
console.warn(
857+
message +
858+
" Reload the page if it stays out of date once the frontend rebuilds.",
859+
);
860+
return;
861+
}
862+
const last_reload =
863+
parseInt(window.sessionStorage.getItem(LAST_RELOADED_KEY)) || 0;
864+
if (Date.now() - last_reload > info.reload_cooldown_ms) {
865+
reloadForNewVersion();
866+
return;
867+
}
868+
console.warn(message + " Reload the page to get the new version.");
869+
showNewVersionBanner();
870+
};
871+
795872
/**
796873
* Connect to a websocket and set the handlers.
797874
* @param socket The socket object to connect.
@@ -824,14 +901,16 @@ export const connect = async (
824901

825902
// The hydrate event rides in the socket.io CONNECT packet, so the backend
826903
// starts loading state as soon as the namespace connects instead of after
827-
// an extra round trip for the connect acknowledgement. The key is read by
828-
// the backend as CompileVars.CONNECT_AUTH_EVENT.
904+
// an extra round trip for the connect acknowledgement. The keys are read by
905+
// the backend as CompileVars.CONNECT_AUTH_EVENT and CONNECT_AUTH_STATE_SCHEMA;
906+
// the state schema goes along on every (re)connect, since a reconnect can
907+
// land on another backend version.
829908
const bootAuth = (first) => {
830909
const boot_event = withRouterData(app.initialEvents(first)[0], params);
831910
recordSentStorageValues(boot_event);
832911
// Instrumentation hook (installed by reflex-otel): may add a traceparent.
833912
window.__reflex_otel?.onEventSend?.(boot_event);
834-
return { event: boot_event };
913+
return { event: boot_event, state_schema: app.stateSchema };
835914
};
836915

837916
// Create the socket. A new session's token is saved here, once the app has
@@ -1041,6 +1120,7 @@ export const connect = async (
10411120
token = new_token;
10421121
window.sessionStorage.setItem(TOKEN_KEY, new_token);
10431122
});
1123+
socket.current.on(STATE_MISMATCH_EVENT, handleStateMismatch);
10441124

10451125
document.addEventListener("visibilitychange", checkVisibility);
10461126
socket.current.connect();

‎packages/reflex-base/src/reflex_base/compiler/templates.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ def context_template(
313313
initial_state_json: str | None = None,
314314
initial_state_hashes: list[str] | None = None,
315315
state_name: str | None = None,
316+
state_schema: str | None = None,
316317
client_storage: dict[str, dict[str, dict[str, Any]]] | None = None,
317318
disable_react_owner_stacks: bool = False,
318319
):
@@ -325,6 +326,10 @@ def context_template(
325326
state name order, sent with the first hydrate so the backend can
326327
skip vars still at their default.
327328
state_name: The name of the state.
329+
state_schema: Hash of the state tree (names, frontend vars, event
330+
handlers) this frontend is compiled for, sent on every socket
331+
connect so the backend can detect a frontend built for another
332+
version of the app.
328333
client_storage: The client storage for the context.
329334
is_dev_mode: Whether the app is in development mode.
330335
default_color_mode: The default color mode for the context.
@@ -452,6 +457,7 @@ def context_template(
452457
export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext }};
453458
export const initialState = {initial_state_json}
454459
export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)}
460+
export const stateSchema = {json.dumps(state_schema)}
455461
456462
export const defaultColorMode = {default_color_mode}
457463
export const StateContexts = {{{state_contexts_str}}};
@@ -467,6 +473,7 @@ def context_template(
467473
initialState,
468474
clientStorage,
469475
state_name,
476+
stateSchema,
470477
exception_state_name,
471478
onLoadInternalEvent,
472479
initialEvents,

‎packages/reflex-base/src/reflex_base/constants/compiler.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ class CompileVars(SimpleNamespace):
6161
HYDRATE_AND_LOAD = "hydrate_and_load"
6262
# The key of the socket.io CONNECT auth packet that carries the boot event.
6363
CONNECT_AUTH_EVENT = "event"
64+
# The key of the CONNECT auth packet that carries the hash of the state
65+
# tree the frontend was compiled for; an old frontend sends none.
66+
CONNECT_AUTH_STATE_SCHEMA = "state_schema"
6467
# Payload keys of hydrate_and_load / update_vars_internal; they are passed
6568
# through as handler kwargs, so they must match those parameter names.
6669
PAYLOAD_VARS = "vars"

‎packages/reflex-base/src/reflex_base/constants/event.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ class SocketEvent(SimpleNamespace):
5050
PING = "ping"
5151
EVENT = "event"
5252
CLIENT_ERROR = "client_error"
53+
# Sent on connect when the frontend was compiled for another state tree
54+
# (must match STATE_MISMATCH_EVENT in .templates/web/utils/state.js).
55+
STATE_MISMATCH = "state_mismatch"
5356

5457
def __str__(self) -> str:
5558
"""Get the string representation of the event name.

‎reflex/app.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Sequence,
2929
)
3030
from contextvars import Token
31+
from datetime import timedelta
3132
from pathlib import Path
3233
from types import SimpleNamespace
3334
from typing import TYPE_CHECKING, Any, overload
@@ -2167,11 +2168,35 @@ async def on_connect(self, sid: str, environ: dict, auth: Any = None):
21672168
if isinstance(self._token_manager, RedisTokenManager):
21682169
# Make sure this instance is watching for updates from other instances.
21692170
self._token_manager.ensure_lost_and_found_task(self.emit_update)
2170-
boot_event = (
2171-
auth.get(constants.CompileVars.CONNECT_AUTH_EVENT)
2172-
if isinstance(auth, dict)
2173-
else None
2174-
)
2171+
boot_event = frontend_schema = None
2172+
if isinstance(auth, dict):
2173+
boot_event = auth.get(constants.CompileVars.CONNECT_AUTH_EVENT)
2174+
frontend_schema = auth.get(constants.CompileVars.CONNECT_AUTH_STATE_SCHEMA)
2175+
# A frontend compiled for another state tree is told so before it
2176+
# hydrates; an older frontend sends no hash and is left alone. The
2177+
# backend hash is cached on the state class, so this is one lookup.
2178+
if (
2179+
frontend_schema is not None
2180+
and self.app._state is not None
2181+
and frontend_schema
2182+
!= (backend_schema := self.app._state._to_frontend_schema())
2183+
):
2184+
logger.warning(
2185+
"Frontend state schema "
2186+
f"{format.sanitize_client_log_value(frontend_schema, max_length=64)} "
2187+
f"for session {sid} does not match the backend state schema "
2188+
f"{backend_schema}: the frontend was built for another version of "
2189+
"the app and is told to reload."
2190+
)
2191+
await self.emit(
2192+
str(constants.SocketEvent.STATE_MISMATCH),
2193+
{
2194+
"backend": backend_schema,
2195+
"reload_cooldown_ms": auto_reload_cooldown()
2196+
// timedelta(milliseconds=1),
2197+
},
2198+
to=sid,
2199+
)
21752200
query_params = urllib.parse.parse_qs(environ.get("QUERY_STRING", ""))
21762201
token_list = query_params.get("token", [])
21772202
if token_list:

‎reflex/compiler/compiler.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ def _compile_contexts(
272272
initial_state_json=initial_state_json,
273273
initial_state_hashes=state_snapshot_hashes(initial_state),
274274
state_name=state.get_name(),
275+
state_schema=state._to_frontend_schema(),
275276
client_storage=utils.compile_client_storage(state),
276277
is_dev_mode=not is_prod_mode(),
277278
default_color_mode=default_color_mode,

‎reflex/state.py‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import functools
99
import hashlib
1010
import inspect
11+
import json
1112
import logging
13+
import operator
1214
import pickle
1315
import re
1416
import sys
@@ -1105,8 +1107,10 @@ def _init_var_dependency_dicts(cls):
11051107
parent_state.get_parent_state(),
11061108
)
11071109

1108-
# Reset cached schema value
1110+
# Reset cached schema values; the frontend schema spans the tree, so a
1111+
# change to any state invalidates every cached root.
11091112
cls._to_schema.cache_clear()
1113+
_frontend_schema.cache_clear()
11101114

11111115
@classmethod
11121116
def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]:
@@ -2152,6 +2156,22 @@ def _field_tuple(
21522156
)
21532157
).hexdigest()
21542158

2159+
@classmethod
2160+
def _to_frontend_schema(cls) -> str:
2161+
"""Hash what a compiled frontend depends on, over the whole state tree.
2162+
2163+
For each state below (and including) this one: its full name, the
2164+
names and types of its frontend vars (base and computed), and its
2165+
event handler names. Defaults and values are left out, so a frontend
2166+
compiled from one tree matches a backend running another exactly when
2167+
the two trees are compatible. The compiler writes this hash into the
2168+
frontend and the backend compares it on each socket connect.
2169+
2170+
Returns:
2171+
The hash of the frontend schema.
2172+
"""
2173+
return _frontend_schema(cls, RegistrationContext.get())
2174+
21552175
def _serialize(self) -> bytes:
21562176
"""Serialize the state for redis.
21572177
@@ -2246,6 +2266,48 @@ def _deserialize(
22462266
return state
22472267

22482268

2269+
@functools.lru_cache
2270+
def _frontend_schema(root: type[BaseState], context: RegistrationContext) -> str:
2271+
"""Hash the frontend schema of the state tree below ``root``.
2272+
2273+
The tree is read from ``context``, which is part of the cache key: apps in
2274+
one process run on forked registration contexts that share the root class
2275+
but not its substates. ``BaseState._init_var_dependency_dicts`` clears the
2276+
cache whenever a state is added or changed.
2277+
2278+
Args:
2279+
root: The state at the top of the tree.
2280+
context: The registration context holding the tree.
2281+
2282+
Returns:
2283+
The hash of the frontend schema.
2284+
"""
2285+
2286+
def _state_entry(state: type[BaseState]) -> list[Any]:
2287+
fields = state.get_fields()
2288+
return [
2289+
state.get_full_name(),
2290+
sorted(
2291+
(name, _serialize_type(fields[name].type_)) for name in state.base_vars
2292+
),
2293+
sorted(
2294+
(name, _serialize_type(cvar._var_type))
2295+
for name, cvar in state.computed_vars.items()
2296+
if not cvar._backend
2297+
),
2298+
sorted(state.event_handlers),
2299+
]
2300+
2301+
entries = []
2302+
pending = [root]
2303+
while pending:
2304+
state = pending.pop()
2305+
entries.append(_state_entry(state))
2306+
pending.extend(context.get_substates(state))
2307+
entries.sort(key=operator.itemgetter(0))
2308+
return md5(json.dumps(entries, separators=(",", ":")).encode("utf-8")).hexdigest()
2309+
2310+
22492311
def _legacy_state_schema(state_cls: type[BaseState]) -> str:
22502312
"""Hash the previous schema format to restore compatible persisted states.
22512313

0 commit comments

Comments
 (0)