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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This example cannot run as shown because rx is undefined. Add import reflex as rx before constructing the app.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/utility_methods/exception_handlers.md, line 32:

<comment>This example cannot run as shown because `rx` is undefined. Add `import reflex as rx` before constructing the app.</comment>

<file context>
@@ -26,6 +26,25 @@ On a mismatch:
+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(
+    head_components=[
+        rx.script(
</file context>
Suggested change
app = rx.App(
import reflex as rx
app = rx.App(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. A plain ```python block is display-only (only exec/demo/eval blocks run), and short rx.App(...) config snippets in the docs routinely start without the import, e.g. docs/styling/theming.md, docs/styling/custom-stylesheets.md and docs/styling/tailwind.md (21 such snippets). The snippet only shows where the listener goes.

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.

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 @@ -32,6 +32,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 @@ -862,8 +866,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 @@ -877,6 +883,13 @@ const handleStateMismatch = (info) => {
);
return;
}
const event = new CustomEvent(STATE_MISMATCH_WINDOW_EVENT, {
cancelable: true,
detail: { frontend: app.stateSchema, backend: info.backend },
});
if (!window.dispatchEvent(event)) {
return;
}
const last_reload =
parseInt(window.sessionStorage.getItem(LAST_RELOADED_KEY)) || 0;
if (Date.now() - last_reload > info.reload_cooldown_ms) {
Expand Down
159 changes: 112 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.
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,68 @@ 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):
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)
48 changes: 48 additions & 0 deletions 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 @@ -79,6 +81,7 @@ async function createConnection(
sessionStorage,
addEventListener() {},
removeEventListener() {},
dispatchEvent: (event) => windowEvents.dispatchEvent(event),
},
document: {
body,
Expand Down Expand Up @@ -122,6 +125,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 @@ -230,12 +234,56 @@ 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]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Time calculation lacks required explanation

The new test uses Date.now() - 1000 without saying that it means one second ago. The repository requires comments explaining time calculations in human-readable terms. Add that duration to the comment before merging.

Suggested change
for (const lastReload of [undefined, Date.now() - 1000]) {
// The recent reload happened one second ago.
for (const lastReload of [undefined, Date.now() - 1000]) {

Rule Used: When using time-based calculations in code, include comments that explain the time duration in human-readable terms (e.g., '# 2 days in milli'). (source)

Learned From
reflex-dev/flexgen#2190

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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());
assert.equal(c.warn.mock.callCount(), 0);

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