diff --git a/docs/utility_methods/exception_handlers.md b/docs/utility_methods/exception_handlers.md index af5150c3f50..2414e7486f3 100644 --- a/docs/utility_methods/exception_handlers.md +++ b/docs/utility_methods/exception_handlers.md @@ -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( + 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 diff --git a/packages/reflex-base/news/+state-mismatch-event.feature.md b/packages/reflex-base/news/+state-mismatch-event.feature.md new file mode 100644 index 00000000000..3c6be024608 --- /dev/null +++ b/packages/reflex-base/news/+state-mismatch-event.feature.md @@ -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. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index aa79aedf662..5bdc381f25b 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -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"; @@ -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) => { @@ -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) { diff --git a/tests/integration/tests_playwright/test_client_error.py b/tests/integration/tests_playwright/test_client_error.py index 0bfb396d61e..0dddff81220 100644 --- a/tests/integration/tests_playwright/test_client_error.py +++ b/tests/integration/tests_playwright/test_client_error.py @@ -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 @@ -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() @@ -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) + 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. @@ -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. @@ -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) diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 4eabef315bf..a8e88657ad7 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -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. */ @@ -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)); } @@ -79,6 +81,7 @@ async function createConnection( sessionStorage, addEventListener() {}, removeEventListener() {}, + dispatchEvent: (event) => windowEvents.dispatchEvent(event), }, document: { body, @@ -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")({ @@ -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]) { + 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);