feat: let the app handle a state schema mismatch

BE
7h ago
+195-49

Since #7553, a production page that connects to a backend with another state schema reloads at once, or shows the "new version available" banner when it already reloaded inside REFLEX_AUTO_RELOAD_COOLDOWN. On a rolling deploy every open tab reconnects to a new pod within seconds, so a deploy that adds a var or renames an event handler reloads all of them in the middle of whatever the user is doing: a drag, a drawing or input not yet sent is lost. The app knows better when a reload is harmless, but it has no way to make that call; the only lever is the cooldown, which the reload on frontend errors shares.

Before it reloads or shows the banner, handleStateMismatch now dispatches a cancelable reflex:state-mismatch event on window, with the state schema hashes of both sides:

new CustomEvent("reflex:state-mismatch", {
  cancelable: true,
  detail: { frontend: app.stateSchema, backend: info.backend },
});

A listener that calls preventDefault() skips both the reload and the banner, so the app can show its own notice, reload after an autosave or when the user pauses, or do nothing. This is the second option from the issue: it is frontend-only, adds no config, and covers the "reload", "banner" and "none" policies of the first option as well as anything in between. Detection, the hash, the cooldown and the banner are unchanged. Without a listener, or with one that does not cancel the event, a page behaves exactly as before.

In dev the event is not dispatched. That path only warns, because a backend hot reload can reconnect before Vite serves the rebuilt bundle; a listener that reloads would turn that short-lived mismatch into reloads during development.

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 mismatched connect and reconnect, also after a reload the app started itself. The cooldown only guards the reload Reflex does on its own, which docs/utility_methods/exception_handlers.md now points out next to the example.

Tests:

  • tests/units/reflex_base/client_state_updates.mjs runs the real state.js: the event is dispatched before the reload and carries both hashes; canceling it skips the reload and the banner (with and without a recent reload), leaves the cooldown untouched and lets events through; a dev page does not dispatch it. The two prod tests fail without the change.
  • tests/integration/tests_playwright/test_client_error.py gains a /handled page whose rx.script cancels the event. In prod the page stays on its first navigation, shows no banner and still handles events; in dev it only warns. The extra backend state the mismatch tests add moved into a fixture both tests share. The prod case fails without the change.

uv run pytest tests/units and uv run pytest tests/integration/tests_playwright/test_client_error.py (dev and prod) pass, and the pre-commit hooks are clean. I also checked the documented setup, an rx.script in head_components, against a dev and a prod build.

Closes #7563

šŸ¤– Generated with Claude Code