Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/utility_methods/exception_handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ On a mismatch:

The backend logs one warning per mismatched connection.

An immediate reload can cost the user input not yet sent or an interaction in progress, for example when a rolling deploy reconnects every open tab to a new backend. Before it reloads or shows the banner, a production page dispatches a cancelable `reflex:state-mismatch` event on `window`, with the state schema hashes of the frontend and the backend in `event.detail.frontend` and `event.detail.backend`. A listener that calls `event.preventDefault()` skips both, and the app decides what to show and when to reload:

```python
app = rx.App(
Comment thread
masenf marked this conversation as resolved.
Resolved
head_components=[
rx.script(
"""
window.addEventListener("reflex:state-mismatch", (event) => {
event.preventDefault();
// Show your own notice, and reload when it is safe.
});
"""
),
],
)
```

The event comes with every connect and reconnect that hits a mismatch, also after a reload the app started itself. The reload cooldown only guards the reload Reflex does on its own, so an app that reloads needs its own guard against a reload loop. In development, the event is not dispatched, so try your listener with `reflex run --env prod` or an exported build.

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.

```md alert warning
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On a state schema mismatch, a production page now dispatches a cancelable `reflex:state-mismatch` event on `window` before it reloads or shows the "new version available" banner. A listener that calls `event.preventDefault()` skips both, so the app decides when to reload; `event.detail` carries the `frontend` and `backend` state schema hashes.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const LAST_RELOADED_KEY = "reflex_last_reloaded_on_error";
// The id of the banner offering a reload when one already happened recently.
const NEW_VERSION_BANNER_ID = "reflex-new-version-banner";

// The cancelable window event dispatched before a prod page acts on a state
// mismatch; a listener calling preventDefault() handles the mismatch instead.
const STATE_MISMATCH_WINDOW_EVENT = "reflex:state-mismatch";

// Client error types (must match reflex_base/constants/event.py ClientErrorType)
const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing";
const ERROR_TYPE_STATE_UPDATE = "state_update_processing_error";
Expand Down Expand Up @@ -867,8 +871,10 @@ const showNewVersionBanner = () => {
* In prod the page reloads once per cooldown window. A mismatch inside the
* window means the reload landed on the same skew (a cached bundle, or several
* backend versions behind one load balancer), so a banner with a reload button
* shows instead and the page never loops. In dev a backend hot reload can
* reconnect before Vite serves the rebuilt bundle, so it only warns.
* shows instead and the page never loops. The app can take over first: a
* listener on the window's STATE_MISMATCH_WINDOW_EVENT that calls
* preventDefault() skips both. In dev a backend hot reload can reconnect
* before Vite serves the rebuilt bundle, so it only warns.
* @param info The backend's state schema hash and the reload cooldown in ms.
*/
const handleStateMismatch = (info) => {
Expand All @@ -882,13 +888,20 @@ const handleStateMismatch = (info) => {
);
return;
}
console.warn(message);
const event = new CustomEvent(STATE_MISMATCH_WINDOW_EVENT, {
cancelable: true,
detail: { frontend: app.stateSchema, backend: info.backend },
});
if (!window.dispatchEvent(event)) {
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
return;
}
const last_reload =
parseInt(window.sessionStorage.getItem(LAST_RELOADED_KEY)) || 0;
if (Date.now() - last_reload > info.reload_cooldown_ms) {
reloadForNewVersion();
return;
}
console.warn(message + " Reload the page to get the new version.");
showNewVersionBanner();
};

Expand Down
161 changes: 114 additions & 47 deletions tests/integration/tests_playwright/test_client_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
A frontend compiled for another state tree is also detected when it connects:
it sends the hash of its tree, and the backend answers a mismatch with the
``state_mismatch`` event. In prod the page reloads once per cooldown window and
otherwise shows a "new version available" banner; in dev it only warns.
otherwise shows a "new version available" banner, unless the app cancels the
``reflex:state-mismatch`` window event; in dev it only warns.
"""

from __future__ import annotations
Expand Down Expand Up @@ -73,6 +74,16 @@ def index():
),
)

@rx.page("/handled")
def handled():
return rx.fragment(
rx.script(
"window.addEventListener('reflex:state-mismatch', (event) => {"
" event.preventDefault(); window.__state_mismatch = event.detail; });"
),
index(),
)

app = rx.App()


Expand Down Expand Up @@ -232,6 +243,44 @@ def client_error_app_env(
yield harness


@pytest.fixture
def mismatch_reports(
client_error_app_env: AppHarness, monkeypatch: pytest.MonkeyPatch
) -> Generator[list[str], None, None]:
"""Give the backend a state the compiled frontend lacks.

Args:
client_error_app_env: Running AppHarness instance.
monkeypatch: pytest fixture for patching the exception handler.

Yields:
The frontend exceptions reported to the backend.
"""
assert client_error_app_env.app_instance is not None
reports: list[str] = []
monkeypatch.setattr(
client_error_app_env.app_instance,
"frontend_exception_handler",
lambda exc: reports.append(str(exc)),
)

class ClientErrorExtraState(State):
extra: int = 0

try:
yield reports
finally:
context = RegistrationContext.get()
context.get_substates(State).discard(ClientErrorExtraState)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
context.base_states.pop(ClientErrorExtraState.get_full_name(), None)
for name, registered in list(context.event_handlers.items()):
if ClientErrorExtraState in registered.states:
del context.event_handlers[name]
all_base_state_classes.pop(ClientErrorExtraState.get_full_name(), None)
State.get_class_substate.cache_clear()
_frontend_schema.cache_clear()


def _is_mismatch_warning(msg: ConsoleMessage) -> bool:
"""Whether a console message is the frontend's state mismatch warning.

Expand Down Expand Up @@ -271,9 +320,7 @@ def test_matching_schema_shows_nothing(client_error_app_env: AppHarness, page: P


def test_state_mismatch_prompts_reload(
client_error_app_env: AppHarness,
page: Page,
monkeypatch: pytest.MonkeyPatch,
client_error_app_env: AppHarness, page: Page, mismatch_reports: list[str]
):
"""A frontend compiled without a state the backend has is told to reload.

Expand All @@ -287,50 +334,70 @@ def test_state_mismatch_prompts_reload(
Args:
client_error_app_env: Running AppHarness instance.
page: Playwright page fixture.
monkeypatch: pytest fixture for patching the exception handler.
mismatch_reports: The frontend exceptions reported to the backend.
"""
assert client_error_app_env.frontend_url is not None
assert client_error_app_env.app_instance is not None
reports: list[str] = []
monkeypatch.setattr(
client_error_app_env.app_instance,
"frontend_exception_handler",
lambda exc: reports.append(str(exc)),
)
if isinstance(client_error_app_env, AppHarnessProd):
page.goto(client_error_app_env.frontend_url)
# The first mismatch reloads the page at once.
page.wait_for_function(f"{NAVIGATION_TYPE} === 'reload'")
# The reload hits the same mismatch inside the cooldown: a banner.
banner = page.locator(NEW_VERSION_BANNER)
expect(banner).to_be_visible()
expect(banner).to_contain_text("new version")
page.evaluate("() => { window.__same_document = true; }")
page.wait_for_timeout(1500)
assert page.evaluate("() => window.__same_document") is True
expect(page.locator(NEW_VERSION_BANNER)).to_have_count(1)
# The button reloads and restarts the cooldown.
banner.get_by_role("button", name="Reload").click()
page.wait_for_function("() => window.__same_document === undefined")
expect(page.locator(NEW_VERSION_BANNER)).to_be_visible()
else:
with page.expect_console_message(_is_mismatch_warning):
page.goto(client_error_app_env.frontend_url)
page.wait_for_timeout(1000)
assert page.evaluate(NAVIGATION_TYPE) == "navigate"
expect(page.locator(NEW_VERSION_BANNER)).to_have_count(0)
# The unknown-substate fallback stays in place next to the detection.
assert AppHarness._poll_for(lambda: mismatch_reports)
assert "no dispatch function" in mismatch_reports[0]

class ClientErrorExtraState(State):
extra: int = 0

try:
if isinstance(client_error_app_env, AppHarnessProd):
page.goto(client_error_app_env.frontend_url)
# The first mismatch reloads the page at once.
page.wait_for_function(f"{NAVIGATION_TYPE} === 'reload'")
# The reload hits the same mismatch inside the cooldown: a banner.
banner = page.locator(NEW_VERSION_BANNER)
expect(banner).to_be_visible()
expect(banner).to_contain_text("new version")
page.evaluate("() => { window.__same_document = true; }")
page.wait_for_timeout(1500)
assert page.evaluate("() => window.__same_document") is True
expect(page.locator(NEW_VERSION_BANNER)).to_have_count(1)
# The button reloads and restarts the cooldown.
banner.get_by_role("button", name="Reload").click()
page.wait_for_function("() => window.__same_document === undefined")
expect(page.locator(NEW_VERSION_BANNER)).to_be_visible()
else:
with page.expect_console_message(_is_mismatch_warning):
page.goto(client_error_app_env.frontend_url)
page.wait_for_timeout(1000)
assert page.evaluate(NAVIGATION_TYPE) == "navigate"
expect(page.locator(NEW_VERSION_BANNER)).to_have_count(0)
# The unknown-substate fallback stays in place next to the detection.
assert AppHarness._poll_for(lambda: reports)
assert "no dispatch function" in reports[0]
finally:
context = RegistrationContext.get()
context.get_substates(State).discard(ClientErrorExtraState)
context.base_states.pop(ClientErrorExtraState.get_full_name(), None)
all_base_state_classes.pop(ClientErrorExtraState.get_full_name(), None)
State.get_class_substate.cache_clear()
_frontend_schema.cache_clear()
def test_state_mismatch_handled_by_app(
client_error_app_env: AppHarness, page: Page, mismatch_reports: list[str]
):
"""An app that cancels the mismatch window event keeps the page as it is.

In prod the page dispatches ``reflex:state-mismatch`` before it reloads or
shows the banner; the page's listener calls preventDefault(), so neither
happens and the page keeps working. In dev the event is not dispatched and
the page only warns, as without a listener.

Args:
client_error_app_env: Running AppHarness instance.
page: Playwright page fixture.
mismatch_reports: The frontend exceptions reported to the backend.
"""
assert client_error_app_env.frontend_url is not None
url = f"{client_error_app_env.frontend_url.rstrip('/')}/handled"
if isinstance(client_error_app_env, AppHarnessProd):
# The console names both hashes although the app handles the mismatch.
with page.expect_console_message(_is_mismatch_warning):
page.goto(url)
page.wait_for_function("() => window.__state_mismatch !== undefined")
detail = page.evaluate("() => window.__state_mismatch")
assert detail["frontend"]
assert detail["backend"]
assert detail["frontend"] != detail["backend"]
else:
with page.expect_console_message(_is_mismatch_warning):
page.goto(url)
assert page.evaluate("() => window.__state_mismatch") is None
expect(page.locator("#token")).not_to_have_value("")
page.click("#bump-btn")
expect(page.locator("#counter")).to_have_text("1")
assert page.evaluate(NAVIGATION_TYPE) == "navigate"
expect(page.locator(NEW_VERSION_BANNER)).to_have_count(0)
assert page.evaluate(f"sessionStorage.getItem('{LAST_RELOADED_KEY}')") is None
assert AppHarness._poll_for(lambda: mismatch_reports)
56 changes: 55 additions & 1 deletion tests/units/reflex_base/client_state_updates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const source = fs.readFileSync(process.argv[2], "utf8");
// sessionStorage key shared with the auto reload on frontend errors.
const LAST_RELOADED_KEY = "reflex_last_reloaded_on_error";
const BANNER_ID = "reflex-new-version-banner";
const MISMATCH_EVENT = "reflex:state-mismatch";
const FRONTEND_SCHEMA = "frontend-schema";

/** A DOM element stub: enough for the banner the runtime builds. */
Expand Down Expand Up @@ -36,6 +37,7 @@ async function createConnection(
const saved = new Map();
const reloads = [];
const session = new Map([["token", "test-token"]]);
const windowEvents = new EventTarget();
if (lastReload !== undefined) {
session.set(LAST_RELOADED_KEY, String(lastReload));
}
Expand Down Expand Up @@ -87,6 +89,7 @@ async function createConnection(
sessionStorage,
addEventListener() {},
removeEventListener() {},
dispatchEvent: (event) => windowEvents.dispatchEvent(event),
},
document: {
body,
Expand Down Expand Up @@ -132,6 +135,7 @@ async function createConnection(
socket.connected = false;
socketRef.current.reconnect();
},
windowEvents,
banner: () => body.children.find((node) => node.id === BANNER_ID),
mismatch: (cooldown = 10000) =>
handlers.get("state_mismatch")({
Expand Down Expand Up @@ -202,7 +206,8 @@ test("a mismatch in prod reloads once per cooldown, then shows a banner", async
assert.ok(banner);
assert.equal(banner.attributes.role, "alert");
assert.match(banner.children[0], /new version/i);
assert.equal(c.warn.mock.callCount(), 2);
// Every mismatch warns once, the one that reloaded included.
assert.equal(c.warn.mock.callCount(), 3);
const button = banner.children.find((node) => node.tag === "button");
assert.equal(button.textContent, "Reload");

Expand Down Expand Up @@ -241,12 +246,61 @@ test("a reload inside the cooldown window is skipped, outside it is taken", asyn
assert.equal(stale.banner(), undefined);
});

test("a mismatch in prod dispatches a cancelable window event first", async (t) => {
const c = await createConnection(t);
const seen = [];
c.windowEvents.addEventListener(MISMATCH_EVENT, (event) =>
seen.push({
cancelable: event.cancelable,
detail: event.detail,
reloads: c.reloads.length,
}),
);
c.mismatch();
assert.deepEqual(seen, [
{
cancelable: true,
detail: { frontend: FRONTEND_SCHEMA, backend: "backend-schema" },
reloads: 0,
},
]);
// A listener that does not cancel it leaves the default action in place.
assert.equal(c.reloads.length, 1);
});

test("canceling the window event skips the reload and the banner", async (t) => {
// Without a recent reload the default would reload, with one show the banner.
for (const lastReload of [undefined, Date.now() - 1000]) {
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
const c = await createConnection(t, {}, { lastReload });
c.windowEvents.addEventListener(MISMATCH_EVENT, (event) =>
event.preventDefault(),
);
c.mismatch();
c.mismatch();
assert.equal(c.reloads.length, 0);
assert.equal(c.banner(), undefined);
assert.equal(c.session.get(LAST_RELOADED_KEY), lastReload?.toString());
// The console still names both hashes when the app handles the mismatch.
assert.equal(c.warn.mock.callCount(), 2);
assert.match(
c.warn.mock.calls[0].arguments[0],
/frontend-schema.*backend-schema/,
);

await c.enqueue([{ name: "test.event", payload: { id: 1 } }]);
assert.deepEqual(c.sent.at(-1).value.payload, { id: 1 });
}
});

test("a mismatch in dev only warns", async (t) => {
const c = await createConnection(t, {}, { isDevMode: true });
const listener = t.mock.fn();
c.windowEvents.addEventListener(MISMATCH_EVENT, listener);
c.mismatch();
c.mismatch();
assert.equal(c.warn.mock.callCount(), 2);
assert.match(c.warn.mock.calls[0].arguments[0], /backend-schema/);
assert.equal(listener.mock.callCount(), 0);
assert.equal(c.reloads.length, 0);
assert.equal(c.banner(), undefined);
assert.equal(c.session.has(LAST_RELOADED_KEY), false);
Expand Down
Loading