Repository navigation
feat: let the app handle a state schema mismatch #7653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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]) { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new test uses
Suggested change
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 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); | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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
rxis undefined. Addimport reflex as rxbefore constructing the app.Prompt for AI agents
There was a problem hiding this comment.
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
```pythonblock is display-only (onlyexec/demo/evalblocks run), and shortrx.App(...)config snippets in the docs routinely start without the import, e.g.docs/styling/theming.md,docs/styling/custom-stylesheets.mdanddocs/styling/tailwind.md(21 such snippets). The snippet only shows where the listener goes.