From 344ae42cd49d59ae5a03aa3fcf84356c430ac18a Mon Sep 17 00:00:00 2001 From: Anish Gurjar <50738829+anishgurjar@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:37:32 -0400 Subject: [PATCH 1/8] fix: sync shared-name storage vars in the writing tab (#7567) --- .../news/+sync-same-tab-shared-name.bugfix.md | 1 + .../reflex_base/.templates/web/utils/state.js | 44 ++++- tests/units/compiler/state_js.test.mjs | 177 +++++++++++++++++- 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md diff --git a/packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md b/packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md new file mode 100644 index 00000000000..3779fe78e50 --- /dev/null +++ b/packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md @@ -0,0 +1 @@ +Synced `LocalStorage` vars that share one storage `name` now update in the tab that writes, not only in other tabs. 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..49c03d42d98 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 @@ -1172,7 +1172,18 @@ export const connect = async ( for (const substate in delta) { dispatch[substate](delta[substate]); } - applyClientStorageDelta(client_storage, delta); + const pending_syncs = applyClientStorageDelta(client_storage, delta); + if (pending_syncs.length > 0) { + queueEvents( + pending_syncs.map(({ key, state_keys }) => + ReflexEvent(SYNC_LOCAL_STORAGE, { key, state_keys }), + ), + socket, + false, + navigate, + params, + ).catch(reportStateUpdateError); + } } if (update.events && update.events.length > 0) { queueEvents(update.events, socket, false, navigate, params).catch( @@ -1363,8 +1374,13 @@ const takeEcho = (state_key, value) => { * Update client storage values based on backend state delta. * @param client_storage The client storage object from context.js * @param delta The state update from the backend + * @returns Storage syncs to send for this tab's sibling vars sharing a + * written storage name, as [{key, state_keys}]. The storage event reaches + * other tabs only, so same-tab siblings are synced from the write here. */ const applyClientStorageDelta = (client_storage, delta) => { + // Syncs to send, keyed by storage name so one name syncs once per delta. + const pending_syncs = new Map(); // find the main state and check for is_hydrated const unqualified_states = Object.keys(delta).filter( (key) => key.split(".").length === 1, @@ -1377,7 +1393,7 @@ const applyClientStorageDelta = (client_storage, delta) => { ) { // skip if the state is not hydrated yet, since all client storage // values are sent in the hydrate event - return; + return []; } } // Save known client storage values to cookies and localStorage. @@ -1395,7 +1411,8 @@ const applyClientStorageDelta = (client_storage, delta) => { state_key in client_storage.local_storage && typeof window !== "undefined" ) { - const name = client_storage.local_storage[state_key].name || state_key; + const options = client_storage.local_storage[state_key]; + const name = options.name || state_key; const echo = takeEcho(state_key, value); const last = lastLocalStorageWrites[name]; // Write an echo only over this tab's own later write, which the backend @@ -1406,11 +1423,31 @@ const applyClientStorageDelta = (client_storage, delta) => { (last?.writes > echo.writes && localStorage.getItem(name) === last.value) ) { + const previous = localStorage.getItem(name); localStorage.setItem(name, value); lastLocalStorageWrites[name] = { value, writes: ++localStorageWrites, }; + // The storage event fires in other tabs only, so queue the sync for + // this tab's other synced vars sharing the name here, whether or + // not the written var itself syncs (storage writes raise the event + // cross-tab either way). Skip an unchanged value: its vars are + // already in sync, and re-sending would ping-pong when an echo + // misses. Deltas are per-session, and a sent update echoes back and + // takeEcho keeps it from writing again. + if (previous !== String(value) && !pending_syncs.has(name)) { + const state_keys = Object.keys(client_storage.local_storage).filter( + (other_key) => + other_key !== state_key && + client_storage.local_storage[other_key].sync && + (client_storage.local_storage[other_key].name || other_key) === + name, + ); + if (state_keys.length > 0) { + pending_syncs.set(name, state_keys); + } + } } } else if ( client_storage.session_storage && @@ -1422,6 +1459,7 @@ const applyClientStorageDelta = (client_storage, delta) => { } } } + return [...pending_syncs].map(([key, state_keys]) => ({ key, state_keys })); }; /** diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 688814a2fde..0c4a88b9400 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -32,6 +32,7 @@ async function setup({ const timers = new Map(); const firstHydrates = []; const updates = []; + const emits = []; const consoleErrors = []; let connectErrors = []; const errorUpdates = []; @@ -44,9 +45,14 @@ async function setup({ }; const localStorage = { items: new Map(), + writes: 0, getItem(key) { return this.items.get(key) ?? null; }, + setItem(key, value) { + this.writes++; + this.items.set(key, String(value)); + }, }; const document = new EventTarget(); document.cookie = disabled ? "backend-enabled=false" : ""; @@ -107,6 +113,9 @@ async function setup({ on(name, handler) { handlers.set(name, handler); }, + emit(name, payload) { + emits.push(payload); + }, connect() { // A ready transport can deliver these synchronously on namespace connect. for (const name of [ @@ -226,6 +235,7 @@ async function setup({ localStorage, socket, updates, + emits, consoleErrors, get connectErrors() { // Like React with pending work, apply queued updaters only on render. @@ -238,13 +248,13 @@ async function setup({ module, dispose, flush: () => microtasks.splice(0).forEach((fn) => fn()), - connect: (transports = ["websocket"]) => + connect: (transports = ["websocket"], storage = {}) => module.namespace.connect( socket, { child: (delta) => updates.push(delta.count) }, transports, (update) => errorUpdates.push(update), - {}, + storage, () => {}, { current: {} }, ), @@ -724,3 +734,166 @@ test("storage names that are Object.prototype properties sync like any other", a JSON.stringify({ third: null }), ); }); + +test("a synced storage write syncs the writing tab's sibling vars", async () => { + const clientStorage = { + local_storage: { + "child.first": { name: "shared", sync: true }, + "child.second": { name: "shared", sync: true }, + "child.third": { name: "shared", sync: true }, + "child.unsynced": { name: "shared" }, + }, + }; + const app = await setup({ clientStorage }); + app.connect(["websocket"], clientStorage); + app.flush(); + const sock = app.sockets[0]; + const syncs = () => + app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); + + // The backend applies a write to one synced var. + sock.handlers.get("event")({ delta: { child: { first: "from-cs0" } } }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + + // The storage event only fires in other tabs, so the writing tab syncs its + // own sibling vars sharing the name. + assert.equal(app.localStorage.items.get("shared"), "from-cs0"); + assert.equal(syncs().length, 1, JSON.stringify(app.emits)); + assert.equal( + JSON.stringify(syncs()[0].payload.vars), + JSON.stringify({ "child.second": "from-cs0", "child.third": "from-cs0" }), + ); + + // The echoed sibling updates neither write again nor queue another sync. + const writes = app.localStorage.writes; + sock.handlers.get("event")({ + delta: { child: { second: "from-cs0", third: "from-cs0" } }, + }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(app.localStorage.writes, writes); + assert.equal(syncs().length, 1, JSON.stringify(app.emits)); +}); + +test("an unsynced write still syncs its synced siblings in the writing tab", async () => { + const clientStorage = { + local_storage: { + "child.first": { name: "shared", sync: true }, + "child.second": { name: "shared" }, + }, + }; + const app = await setup({ clientStorage }); + app.connect(["websocket"], clientStorage); + app.flush(); + const sock = app.sockets[0]; + const syncs = () => + app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); + + // Storage writes raise the cross-tab event whether or not the written var + // syncs, so the writing tab syncs its synced siblings either way. + sock.handlers.get("event")({ delta: { child: { second: "plain" } } }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(app.localStorage.items.get("shared"), "plain"); + assert.equal(syncs().length, 1, JSON.stringify(app.emits)); + assert.equal( + JSON.stringify(syncs()[0].payload.vars), + JSON.stringify({ "child.first": "plain" }), + ); +}); + +test("two same-name vars in one delta queue one sync per storage name", async () => { + const clientStorage = { + local_storage: { + "child.first": { name: "shared", sync: true }, + "child.second": { name: "shared", sync: true }, + "child.third": { name: "other", sync: true }, + "child.fourth": { name: "other", sync: true }, + }, + }; + const app = await setup({ clientStorage }); + app.connect(["websocket"], clientStorage); + app.flush(); + const sock = app.sockets[0]; + const syncs = () => + app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); + + sock.handlers.get("event")({ + delta: { child: { first: "one", second: "two", third: "three" } }, + }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + + // One sync per storage name, reading the name's latest value when sent. + assert.equal(syncs().length, 2, JSON.stringify(app.emits)); + const byVars = syncs().map((e) => JSON.stringify(e.payload.vars)); + assert.ok(byVars.includes(JSON.stringify({ "child.second": "two" }))); + assert.ok(byVars.includes(JSON.stringify({ "child.fourth": "three" }))); +}); + +test("an unhydrated delta writes and syncs no client storage", async () => { + const clientStorage = { + local_storage: { + "child.first": { name: "shared", sync: true }, + "child.second": { name: "shared", sync: true }, + }, + }; + const app = await setup({ clientStorage }); + app.connect(["websocket"], clientStorage); + app.flush(); + const sock = app.sockets[0]; + + sock.handlers.get("event")({ + delta: { child: { is_hydrated_rx_state_: false, first: "early" } }, + }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(app.localStorage.items.get("shared"), undefined); + assert.equal( + app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")).length, + 0, + JSON.stringify(app.emits), + ); +}); + +test("a newer backend value re-syncs once, then the echo terminates it", async () => { + const clientStorage = { + local_storage: { + "child.first": { name: "shared", sync: true }, + "child.second": { name: "shared", sync: true }, + }, + }; + const app = await setup({ clientStorage }); + app.connect(["websocket"], clientStorage); + app.flush(); + const sock = app.sockets[0]; + const syncs = () => + app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); + const deliver = async (delta) => { + sock.handlers.get("event")({ delta }); + app.flush(); + await new Promise((resolve) => setImmediate(resolve)); + }; + + await deliver({ child: { first: "v1" } }); + assert.equal(syncs().length, 1, JSON.stringify(app.emits)); + + // A backend value that matches no sent value overrides storage and syncs + // the siblings once more. + await deliver({ child: { second: "v2" } }); + assert.equal(app.localStorage.items.get("shared"), "v2"); + assert.equal(syncs().length, 2, JSON.stringify(app.emits)); + assert.equal( + JSON.stringify(syncs()[1].payload.vars), + JSON.stringify({ "child.first": "v2" }), + ); + + // That update's echo is recognized: no write, no third sync. No ping-pong. + const writes = app.localStorage.writes; + await deliver({ child: { first: "v2" } }); + assert.equal(app.localStorage.writes, writes); + assert.equal(syncs().length, 2, JSON.stringify(app.emits)); +}); From 37476aa06d442094df7de02a8581517436e1f4fc Mon Sep 17 00:00:00 2001 From: Anish Gurjar <50738829+anishgurjar@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:08:41 -0400 Subject: [PATCH 2/8] Sync every synced var sharing the name, not just the writer's siblings One delta writing two same-name vars with different values would queue a sync that excluded the earlier writer, leaving it on the old value while storage held the last write. Build the sync set from every synced var with the name, like handleStorage, so the last write converges all of them. --- .../reflex_base/.templates/web/utils/state.js | 19 ++++++++++------- tests/units/compiler/state_js.test.mjs | 21 +++++++++++++++---- 2 files changed, 28 insertions(+), 12 deletions(-) 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 49c03d42d98..5a88770eb64 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 @@ -1430,21 +1430,24 @@ const applyClientStorageDelta = (client_storage, delta) => { writes: ++localStorageWrites, }; // The storage event fires in other tabs only, so queue the sync for - // this tab's other synced vars sharing the name here, whether or - // not the written var itself syncs (storage writes raise the event - // cross-tab either way). Skip an unchanged value: its vars are - // already in sync, and re-sending would ping-pong when an echo - // misses. Deltas are per-session, and a sent update echoes back and - // takeEcho keeps it from writing again. + // this tab's synced vars sharing the name here, whether or not the + // written var itself syncs (storage writes raise the event + // cross-tab either way). Like handleStorage, the sync covers every + // synced var with the name: when one delta writes several of them, + // the last write wins in storage, and the sync reads that value. + // Skip an unchanged value: its vars are already in sync, and + // re-sending would ping-pong when an echo misses. Deltas are + // per-session, and a sent update echoes back and takeEcho keeps it + // from writing again. if (previous !== String(value) && !pending_syncs.has(name)) { const state_keys = Object.keys(client_storage.local_storage).filter( (other_key) => - other_key !== state_key && client_storage.local_storage[other_key].sync && (client_storage.local_storage[other_key].name || other_key) === name, ); - if (state_keys.length > 0) { + // Sync only when another var shares the name. + if (state_keys.some((other_key) => other_key !== state_key)) { pending_syncs.set(name, state_keys); } } diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 0c4a88b9400..dc5028a73b5 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -762,7 +762,11 @@ test("a synced storage write syncs the writing tab's sibling vars", async () => assert.equal(syncs().length, 1, JSON.stringify(app.emits)); assert.equal( JSON.stringify(syncs()[0].payload.vars), - JSON.stringify({ "child.second": "from-cs0", "child.third": "from-cs0" }), + JSON.stringify({ + "child.first": "from-cs0", + "child.second": "from-cs0", + "child.third": "from-cs0", + }), ); // The echoed sibling updates neither write again nor queue another sync. @@ -829,8 +833,17 @@ test("two same-name vars in one delta queue one sync per storage name", async () // One sync per storage name, reading the name's latest value when sent. assert.equal(syncs().length, 2, JSON.stringify(app.emits)); const byVars = syncs().map((e) => JSON.stringify(e.payload.vars)); - assert.ok(byVars.includes(JSON.stringify({ "child.second": "two" }))); - assert.ok(byVars.includes(JSON.stringify({ "child.fourth": "three" }))); + // Every synced var with the name converges on the last written value. + assert.ok( + byVars.includes( + JSON.stringify({ "child.first": "two", "child.second": "two" }), + ), + ); + assert.ok( + byVars.includes( + JSON.stringify({ "child.third": "three", "child.fourth": "three" }), + ), + ); }); test("an unhydrated delta writes and syncs no client storage", async () => { @@ -888,7 +901,7 @@ test("a newer backend value re-syncs once, then the echo terminates it", async ( assert.equal(syncs().length, 2, JSON.stringify(app.emits)); assert.equal( JSON.stringify(syncs()[1].payload.vars), - JSON.stringify({ "child.first": "v2" }), + JSON.stringify({ "child.first": "v2", "child.second": "v2" }), ); // That update's echo is recognized: no write, no third sync. No ping-pong. From ffde87e30682644133d75561ee5bdd20cb89567c Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 22 Oct 2026 13:22:38 -0700 Subject: [PATCH 3/8] test: complete local storage fixture for client updates --- tests/units/reflex_base/client_state_updates.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 4eabef315bf..54a8b5fa34a 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -87,7 +87,10 @@ async function createConnection( getElementById: (id) => body.children.find((node) => node.id === id) ?? null, }, - localStorage: { setItem: (key, value) => saved.set(key, value) }, + localStorage: { + getItem: (key) => saved.get(key) ?? null, + setItem: (key, value) => saved.set(key, String(value)), + }, sessionStorage, }, "socket.io-client": { default: () => socket }, From 41d7be6de6dca1ab301568f8fcd3c30ddba96fc7 Mon Sep 17 00:00:00 2001 From: Anish Gurjar <50738829+anishgurjar@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:40:37 -0400 Subject: [PATCH 4/8] Guard same-tab sibling sync against unechoed sent values Skip the sibling sync when any synced var sharing the storage name has an unechoed entry in sentStorageValues, so a stale delta cannot resend an older value while a newer update_vars_internal is in flight. Adds the Playwright regression test requested in review. --- .../reflex_base/.templates/web/utils/state.js | 13 ++++- .../test_hydration_storage.py | 38 +++++++++++++ tests/units/compiler/state_js.test.mjs | 53 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) 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 5a88770eb64..538c5e58bf4 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 @@ -1446,8 +1446,17 @@ const applyClientStorageDelta = (client_storage, delta) => { (client_storage.local_storage[other_key].name || other_key) === name, ); - // Sync only when another var shares the name. - if (state_keys.some((other_key) => other_key !== state_key)) { + // Sync only when another var shares the name. Skip when a synced + // var with the name has an unechoed sent value: this delta replies + // to an older event than that value. The echo of the newer value + // carries every applied key for the name, so storage and the + // sibling vars converge without a sync from this stale delta. + if ( + state_keys.some((other_key) => other_key !== state_key) && + !state_keys.some( + (other_key) => sentStorageValues[other_key]?.length > 0, + ) + ) { pending_syncs.set(name, state_keys); } } diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index f65a4434a0c..963b3fc4e1d 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -149,6 +149,7 @@ def get_delta(self): class SharedNameState(rx.State): synced: str = rx.LocalStorage("", name="hydrate-shared", sync=True) + synced2: str = rx.LocalStorage("", name="hydrate-shared", sync=True) plain: str = rx.LocalStorage("", name="hydrate-shared") @rx.event @@ -160,6 +161,15 @@ def set_plain(self, value: str): """ self.plain = value + @rx.event + def set_synced(self, value: str): + """Store a value through the synced var. + + Args: + value: The value to store. + """ + self.synced = value + def synced(): """Display a browser storage var synced across tabs. @@ -174,11 +184,17 @@ def synced(): rx.text(ReplaceState.plain, id="replace-plain"), rx.button("Keep", on_click=ReplaceState.keep_sent, id="keep-sent"), rx.text(SharedNameState.synced, id="shared-value"), + rx.text(SharedNameState.synced2, id="shared-value-2"), rx.button( "Plain old", on_click=SharedNameState.set_plain("old"), id="set-plain-old", ), + rx.button( + "Synced new", + on_click=SharedNameState.set_synced("new"), + id="set-synced-new", + ), rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"), ) @@ -589,6 +605,28 @@ def test_synced_storage_echo_after_own_write_is_written( assert page.evaluate("key => localStorage.getItem(key)", key) == "sent" +def test_synced_storage_shared_name_syncs_siblings_in_writing_tab( + hydration_storage_app: AppHarness, page: Page +): + """A write to one synced var sharing a storage name updates the others in the writing tab. + + The storage event only fires in other tabs, so the writing tab syncs its + sibling vars sharing the name itself (issue #7567). + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + open_synced(hydration_storage_app, page) + page.locator("#set-synced-new").click() + expect(page.locator("#shared-value")).to_have_text("new") + expect(page.locator("#shared-value-2")).to_have_text("new") + page.wait_for_timeout(500) + expect(page.locator("#shared-value")).to_have_text("new") + expect(page.locator("#shared-value-2")).to_have_text("new") + assert page.evaluate("localStorage.getItem('hydrate-shared')") == "new" + + @pytest.mark.parametrize("other_tab_stores", [False, True], ids=["alone", "other_tab"]) @pytest.mark.parametrize("synced", [True, False], ids=["synced", "plain"]) def test_replaced_storage_echo_does_not_hide_later_change( diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index dc5028a73b5..ee5b6861287 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -872,6 +872,56 @@ test("an unhydrated delta writes and syncs no client storage", async () => { ); }); +test("a stale delta queues no sibling sync while a sent value is unechoed", async () => { + const clientStorage = { + local_storage: { + "child.synced": { name: "shared", sync: true }, + "child.synced2": { name: "shared", sync: true }, + "child.plain": { name: "shared" }, + }, + }; + // One module instance: the storage-event path (sent) and the backend-delta + // path (emits) share the sent-value bookkeeping. + const { harness, effects, sent } = await mountEventLoop(clientStorage); + for (const effect of effects) effect(); + harness.connect(["websocket"], clientStorage); + harness.flush(); + const sock = harness.sockets[0]; + const syncs = () => + harness.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); + + // Another tab stores "sent"; this tab sends it for its synced vars and the + // backend's reply is still out. + harness.localStorage.items.set("shared", "sent"); + const storage_event = new Event("storage"); + storage_event.key = "shared"; + storage_event.storageArea = harness.localStorage; + harness.window.dispatchEvent(storage_event); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(sent.length, 1, JSON.stringify(sent)); + + // A stale handler reply writes an older value through an unsynced var. + sock.handlers.get("event")({ delta: { child: { plain: "old" } } }); + harness.flush(); + await new Promise((resolve) => setImmediate(resolve)); + + // The stale write stores "old" but queues no sibling sync: the unechoed + // "sent" is newer. + assert.equal(harness.localStorage.items.get("shared"), "old"); + assert.equal(syncs().length, 0, JSON.stringify(harness.emits)); + + // The echo of "sent" writes it back over this tab's own newer write. As + // the real backend does, it carries every applied key for the name, so + // storage and the sibling vars converge with no further sync. + sock.handlers.get("event")({ + delta: { child: { synced: "sent", synced2: "sent" } }, + }); + harness.flush(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(harness.localStorage.items.get("shared"), "sent"); + assert.equal(syncs().length, 0, JSON.stringify(harness.emits)); +}); + test("a newer backend value re-syncs once, then the echo terminates it", async () => { const clientStorage = { local_storage: { @@ -893,6 +943,9 @@ test("a newer backend value re-syncs once, then the echo terminates it", async ( await deliver({ child: { first: "v1" } }); assert.equal(syncs().length, 1, JSON.stringify(app.emits)); + // The backend echoes the whole update, clearing the sent entries. + await deliver({ child: { first: "v1", second: "v1" } }); + assert.equal(syncs().length, 1, JSON.stringify(app.emits)); // A backend value that matches no sent value overrides storage and syncs // the siblings once more. From 2a13258243ab81c329e90282d991a3a80378f616 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 23:52:52 +0500 Subject: [PATCH 5/8] Sync shared-name storage vars on the backend Replace the frontend same-tab sync with a backend fan-out. When an event writes a localStorage var, set the other synced vars with the same storage name in the same locked event, before the delta is resolved. The writing tab gets every sibling in one delta and sends no extra sync event. The frontend sync had to track sent values that the backend did not echo. A get_delta override that rewrites the echo left such a value behind, and the guard then suppressed the sibling sync for that name until reload. The backend fan-out keeps no per-tab state, so that cannot happen. --- .../+sync-same-tab-shared-name.bugfix.md | 0 .../reflex_base/.templates/web/utils/state.js | 56 +--- reflex/state.py | 70 +++++ .../test_hydration_storage.py | 75 +++++- tests/units/compiler/state_js.test.mjs | 243 +----------------- .../reflex_base/client_state_updates.mjs | 5 +- tests/units/test_state.py | 55 ++++ 7 files changed, 195 insertions(+), 309 deletions(-) rename {packages/reflex-base/news => news}/+sync-same-tab-shared-name.bugfix.md (100%) diff --git a/packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md b/news/+sync-same-tab-shared-name.bugfix.md similarity index 100% rename from packages/reflex-base/news/+sync-same-tab-shared-name.bugfix.md rename to news/+sync-same-tab-shared-name.bugfix.md 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 538c5e58bf4..aa79aedf662 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 @@ -1172,18 +1172,7 @@ export const connect = async ( for (const substate in delta) { dispatch[substate](delta[substate]); } - const pending_syncs = applyClientStorageDelta(client_storage, delta); - if (pending_syncs.length > 0) { - queueEvents( - pending_syncs.map(({ key, state_keys }) => - ReflexEvent(SYNC_LOCAL_STORAGE, { key, state_keys }), - ), - socket, - false, - navigate, - params, - ).catch(reportStateUpdateError); - } + applyClientStorageDelta(client_storage, delta); } if (update.events && update.events.length > 0) { queueEvents(update.events, socket, false, navigate, params).catch( @@ -1374,13 +1363,8 @@ const takeEcho = (state_key, value) => { * Update client storage values based on backend state delta. * @param client_storage The client storage object from context.js * @param delta The state update from the backend - * @returns Storage syncs to send for this tab's sibling vars sharing a - * written storage name, as [{key, state_keys}]. The storage event reaches - * other tabs only, so same-tab siblings are synced from the write here. */ const applyClientStorageDelta = (client_storage, delta) => { - // Syncs to send, keyed by storage name so one name syncs once per delta. - const pending_syncs = new Map(); // find the main state and check for is_hydrated const unqualified_states = Object.keys(delta).filter( (key) => key.split(".").length === 1, @@ -1393,7 +1377,7 @@ const applyClientStorageDelta = (client_storage, delta) => { ) { // skip if the state is not hydrated yet, since all client storage // values are sent in the hydrate event - return []; + return; } } // Save known client storage values to cookies and localStorage. @@ -1411,8 +1395,7 @@ const applyClientStorageDelta = (client_storage, delta) => { state_key in client_storage.local_storage && typeof window !== "undefined" ) { - const options = client_storage.local_storage[state_key]; - const name = options.name || state_key; + const name = client_storage.local_storage[state_key].name || state_key; const echo = takeEcho(state_key, value); const last = lastLocalStorageWrites[name]; // Write an echo only over this tab's own later write, which the backend @@ -1423,43 +1406,11 @@ const applyClientStorageDelta = (client_storage, delta) => { (last?.writes > echo.writes && localStorage.getItem(name) === last.value) ) { - const previous = localStorage.getItem(name); localStorage.setItem(name, value); lastLocalStorageWrites[name] = { value, writes: ++localStorageWrites, }; - // The storage event fires in other tabs only, so queue the sync for - // this tab's synced vars sharing the name here, whether or not the - // written var itself syncs (storage writes raise the event - // cross-tab either way). Like handleStorage, the sync covers every - // synced var with the name: when one delta writes several of them, - // the last write wins in storage, and the sync reads that value. - // Skip an unchanged value: its vars are already in sync, and - // re-sending would ping-pong when an echo misses. Deltas are - // per-session, and a sent update echoes back and takeEcho keeps it - // from writing again. - if (previous !== String(value) && !pending_syncs.has(name)) { - const state_keys = Object.keys(client_storage.local_storage).filter( - (other_key) => - client_storage.local_storage[other_key].sync && - (client_storage.local_storage[other_key].name || other_key) === - name, - ); - // Sync only when another var shares the name. Skip when a synced - // var with the name has an unechoed sent value: this delta replies - // to an older event than that value. The echo of the newer value - // carries every applied key for the name, so storage and the - // sibling vars converge without a sync from this stale delta. - if ( - state_keys.some((other_key) => other_key !== state_key) && - !state_keys.some( - (other_key) => sentStorageValues[other_key]?.length > 0, - ) - ) { - pending_syncs.set(name, state_keys); - } - } } } else if ( client_storage.session_storage && @@ -1471,7 +1422,6 @@ const applyClientStorageDelta = (client_storage, delta) => { } } } - return [...pending_syncs].map(([key, state_keys]) => ({ key, state_keys })); }; /** diff --git a/reflex/state.py b/reflex/state.py index 9859e0d3c2a..f89befa1da8 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1112,6 +1112,7 @@ def _init_var_dependency_dicts(cls): # change to any state invalidates every cached root. cls._to_schema.cache_clear() _frontend_schema.cache_clear() + _shared_storage_targets.cache_clear() @classmethod def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]: @@ -1953,6 +1954,8 @@ async def _get_resolved_delta(self) -> Delta: Returns: The resolved delta. """ + if not _replay_client_storage.get(): + await _sync_shared_storage(self) return await resolve_delta(self) def _mark_dirty(self, var_names: Iterable[str] | None = None) -> None: @@ -2283,6 +2286,73 @@ def _deserialize( return state +@functools.lru_cache +def _shared_storage_targets( + root: type[BaseState], context: RegistrationContext +) -> dict[tuple[str, str], tuple[tuple[type[BaseState], str], ...]]: + """Map each localStorage var to the other synced vars with its storage name. + + Only vars whose storage name another synced var shares are included. + + Args: + root: The state at the top of the tree. + context: The registration context holding the tree. + + Returns: + The synced vars to set, by the state name and var name of a var. + """ + # The compiler imports this module. + from reflex.compiler.utils import _compile_client_storage_recursive + + by_name: dict[str, list[tuple[str, str, bool]]] = {} + for state_key, options in _compile_client_storage_recursive(root)[1].items(): + if name := options.get("name"): + state_name, _, var_name = state_key.removesuffix(FIELD_MARKER).rpartition( + "." + ) + by_name.setdefault(name, []).append(( + state_name, + var_name, + bool(options.get("sync")), + )) + return { + (state_name, var_name): targets + for members in by_name.values() + for state_name, var_name, _ in members + if ( + targets := tuple( + (root.get_class_substate(other_state), other_var) + for other_state, other_var, sync in members + if sync and (other_state, other_var) != (state_name, var_name) + ) + ) + } + + +async def _sync_shared_storage(state: BaseState) -> None: + """Set the synced vars sharing the storage name of a var this event wrote. + + The browser raises the storage event in other tabs only, so the tab that + stores the value would keep the other vars with its name stale. + + Args: + state: The root of the states to get the delta for. + """ + targets = _shared_storage_targets(state.get_root_state(), RegistrationContext.get()) + for (state_name, var_name), synced in targets.items(): + source = state + for name in state_name.split(".")[1:]: + if (source := source.substates.get(name)) is None: + break + if source is None or var_name not in source.dirty_vars: + continue + value = source.get_value(var_name) + for target_cls, target_var in synced: + target = await state.get_state(target_cls) + if target.get_value(target_var) != value: + setattr(target, target_var, value) + + @functools.lru_cache def _frontend_schema(root: type[BaseState], context: RegistrationContext) -> str: """Hash the frontend schema of the state tree below ``root``. diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index fd6a4679f65..c64098fe6f1 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -186,6 +186,32 @@ def set_synced(self, value: str): """ self.synced = value + class SharedOverrideState(rx.State): + first: str = rx.LocalStorage("", name="hydrate-shared-override", sync=True) + second: str = rx.LocalStorage("", name="hydrate-shared-override", sync=True) + + @rx.event + def set_second(self, value: str): + """Store a value through the second var. + + Args: + value: The value to store. + """ + self.second = value + + @rx.state._override_base_method + def get_delta(self): + """Replace the first var's "x". + + Returns: + The delta, with the value replaced. + """ + delta = super().get_delta() + subdelta = delta.get(self.get_full_name(), {}) + if subdelta.get("first" + FIELD_MARKER) == "x": + subdelta["first" + FIELD_MARKER] = "x-replaced" + return delta + def synced(): """Display a browser storage var synced across tabs. @@ -201,16 +227,24 @@ def synced(): rx.button("Keep", on_click=ReplaceState.keep_sent, id="keep-sent"), rx.text(SharedNameState.synced, id="shared-value"), rx.text(SharedNameState.synced2, id="shared-value-2"), - rx.button( - "Plain old", - on_click=SharedNameState.set_plain("old"), - id="set-plain-old", - ), rx.button( "Synced new", on_click=SharedNameState.set_synced("new"), id="set-synced-new", ), + rx.text(SharedOverrideState.first, id="override-first"), + rx.text(SharedOverrideState.second, id="override-second"), + rx.button( + "Second x", on_click=SharedOverrideState.set_second("x"), id="set-x" + ), + rx.button( + "Second y", on_click=SharedOverrideState.set_second("y"), id="set-y" + ), + rx.button( + "Plain old", + on_click=SharedNameState.set_plain("old"), + id="set-plain-old", + ), rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"), ) @@ -683,23 +717,42 @@ def test_synced_storage_echo_after_own_write_is_written( def test_synced_storage_shared_name_syncs_siblings_in_writing_tab( hydration_storage_app: AppHarness, page: Page ): - """A write to one synced var sharing a storage name updates the others in the writing tab. - - The storage event only fires in other tabs, so the writing tab syncs its - sibling vars sharing the name itself (issue #7567). + """A write to one var sharing a storage name updates the others in the writing tab. Args: hydration_storage_app: The running app. page: A fresh browser page. """ + socket = EventSocket(page) open_synced(hydration_storage_app, page) page.locator("#set-synced-new").click() expect(page.locator("#shared-value")).to_have_text("new") expect(page.locator("#shared-value-2")).to_have_text("new") page.wait_for_timeout(500) - expect(page.locator("#shared-value")).to_have_text("new") - expect(page.locator("#shared-value-2")).to_have_text("new") assert page.evaluate("localStorage.getItem('hydrate-shared')") == "new" + assert not [m for m in socket.sent if "update_vars_internal" in m] + + +def test_synced_storage_shared_name_override_keeps_syncing( + hydration_storage_app: AppHarness, page: Page +): + """A get_delta override replacing one shared-name value leaves later writes synced. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + socket = EventSocket(page) + open_synced(hydration_storage_app, page) + page.locator("#set-x").click() + expect(page.locator("#override-first")).to_have_text("x-replaced") + expect(page.locator("#override-second")).to_have_text("x") + page.locator("#set-y").click() + expect(page.locator("#override-first")).to_have_text("y") + expect(page.locator("#override-second")).to_have_text("y") + page.wait_for_timeout(500) + assert page.evaluate("localStorage.getItem('hydrate-shared-override')") == "y" + assert not [m for m in socket.sent if "update_vars_internal" in m] @pytest.mark.parametrize("other_tab_stores", [False, True], ids=["alone", "other_tab"]) diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index ee5b6861287..688814a2fde 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -32,7 +32,6 @@ async function setup({ const timers = new Map(); const firstHydrates = []; const updates = []; - const emits = []; const consoleErrors = []; let connectErrors = []; const errorUpdates = []; @@ -45,14 +44,9 @@ async function setup({ }; const localStorage = { items: new Map(), - writes: 0, getItem(key) { return this.items.get(key) ?? null; }, - setItem(key, value) { - this.writes++; - this.items.set(key, String(value)); - }, }; const document = new EventTarget(); document.cookie = disabled ? "backend-enabled=false" : ""; @@ -113,9 +107,6 @@ async function setup({ on(name, handler) { handlers.set(name, handler); }, - emit(name, payload) { - emits.push(payload); - }, connect() { // A ready transport can deliver these synchronously on namespace connect. for (const name of [ @@ -235,7 +226,6 @@ async function setup({ localStorage, socket, updates, - emits, consoleErrors, get connectErrors() { // Like React with pending work, apply queued updaters only on render. @@ -248,13 +238,13 @@ async function setup({ module, dispose, flush: () => microtasks.splice(0).forEach((fn) => fn()), - connect: (transports = ["websocket"], storage = {}) => + connect: (transports = ["websocket"]) => module.namespace.connect( socket, { child: (delta) => updates.push(delta.count) }, transports, (update) => errorUpdates.push(update), - storage, + {}, () => {}, { current: {} }, ), @@ -734,232 +724,3 @@ test("storage names that are Object.prototype properties sync like any other", a JSON.stringify({ third: null }), ); }); - -test("a synced storage write syncs the writing tab's sibling vars", async () => { - const clientStorage = { - local_storage: { - "child.first": { name: "shared", sync: true }, - "child.second": { name: "shared", sync: true }, - "child.third": { name: "shared", sync: true }, - "child.unsynced": { name: "shared" }, - }, - }; - const app = await setup({ clientStorage }); - app.connect(["websocket"], clientStorage); - app.flush(); - const sock = app.sockets[0]; - const syncs = () => - app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); - - // The backend applies a write to one synced var. - sock.handlers.get("event")({ delta: { child: { first: "from-cs0" } } }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - - // The storage event only fires in other tabs, so the writing tab syncs its - // own sibling vars sharing the name. - assert.equal(app.localStorage.items.get("shared"), "from-cs0"); - assert.equal(syncs().length, 1, JSON.stringify(app.emits)); - assert.equal( - JSON.stringify(syncs()[0].payload.vars), - JSON.stringify({ - "child.first": "from-cs0", - "child.second": "from-cs0", - "child.third": "from-cs0", - }), - ); - - // The echoed sibling updates neither write again nor queue another sync. - const writes = app.localStorage.writes; - sock.handlers.get("event")({ - delta: { child: { second: "from-cs0", third: "from-cs0" } }, - }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(app.localStorage.writes, writes); - assert.equal(syncs().length, 1, JSON.stringify(app.emits)); -}); - -test("an unsynced write still syncs its synced siblings in the writing tab", async () => { - const clientStorage = { - local_storage: { - "child.first": { name: "shared", sync: true }, - "child.second": { name: "shared" }, - }, - }; - const app = await setup({ clientStorage }); - app.connect(["websocket"], clientStorage); - app.flush(); - const sock = app.sockets[0]; - const syncs = () => - app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); - - // Storage writes raise the cross-tab event whether or not the written var - // syncs, so the writing tab syncs its synced siblings either way. - sock.handlers.get("event")({ delta: { child: { second: "plain" } } }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(app.localStorage.items.get("shared"), "plain"); - assert.equal(syncs().length, 1, JSON.stringify(app.emits)); - assert.equal( - JSON.stringify(syncs()[0].payload.vars), - JSON.stringify({ "child.first": "plain" }), - ); -}); - -test("two same-name vars in one delta queue one sync per storage name", async () => { - const clientStorage = { - local_storage: { - "child.first": { name: "shared", sync: true }, - "child.second": { name: "shared", sync: true }, - "child.third": { name: "other", sync: true }, - "child.fourth": { name: "other", sync: true }, - }, - }; - const app = await setup({ clientStorage }); - app.connect(["websocket"], clientStorage); - app.flush(); - const sock = app.sockets[0]; - const syncs = () => - app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); - - sock.handlers.get("event")({ - delta: { child: { first: "one", second: "two", third: "three" } }, - }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - - // One sync per storage name, reading the name's latest value when sent. - assert.equal(syncs().length, 2, JSON.stringify(app.emits)); - const byVars = syncs().map((e) => JSON.stringify(e.payload.vars)); - // Every synced var with the name converges on the last written value. - assert.ok( - byVars.includes( - JSON.stringify({ "child.first": "two", "child.second": "two" }), - ), - ); - assert.ok( - byVars.includes( - JSON.stringify({ "child.third": "three", "child.fourth": "three" }), - ), - ); -}); - -test("an unhydrated delta writes and syncs no client storage", async () => { - const clientStorage = { - local_storage: { - "child.first": { name: "shared", sync: true }, - "child.second": { name: "shared", sync: true }, - }, - }; - const app = await setup({ clientStorage }); - app.connect(["websocket"], clientStorage); - app.flush(); - const sock = app.sockets[0]; - - sock.handlers.get("event")({ - delta: { child: { is_hydrated_rx_state_: false, first: "early" } }, - }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(app.localStorage.items.get("shared"), undefined); - assert.equal( - app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")).length, - 0, - JSON.stringify(app.emits), - ); -}); - -test("a stale delta queues no sibling sync while a sent value is unechoed", async () => { - const clientStorage = { - local_storage: { - "child.synced": { name: "shared", sync: true }, - "child.synced2": { name: "shared", sync: true }, - "child.plain": { name: "shared" }, - }, - }; - // One module instance: the storage-event path (sent) and the backend-delta - // path (emits) share the sent-value bookkeeping. - const { harness, effects, sent } = await mountEventLoop(clientStorage); - for (const effect of effects) effect(); - harness.connect(["websocket"], clientStorage); - harness.flush(); - const sock = harness.sockets[0]; - const syncs = () => - harness.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); - - // Another tab stores "sent"; this tab sends it for its synced vars and the - // backend's reply is still out. - harness.localStorage.items.set("shared", "sent"); - const storage_event = new Event("storage"); - storage_event.key = "shared"; - storage_event.storageArea = harness.localStorage; - harness.window.dispatchEvent(storage_event); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(sent.length, 1, JSON.stringify(sent)); - - // A stale handler reply writes an older value through an unsynced var. - sock.handlers.get("event")({ delta: { child: { plain: "old" } } }); - harness.flush(); - await new Promise((resolve) => setImmediate(resolve)); - - // The stale write stores "old" but queues no sibling sync: the unechoed - // "sent" is newer. - assert.equal(harness.localStorage.items.get("shared"), "old"); - assert.equal(syncs().length, 0, JSON.stringify(harness.emits)); - - // The echo of "sent" writes it back over this tab's own newer write. As - // the real backend does, it carries every applied key for the name, so - // storage and the sibling vars converge with no further sync. - sock.handlers.get("event")({ - delta: { child: { synced: "sent", synced2: "sent" } }, - }); - harness.flush(); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(harness.localStorage.items.get("shared"), "sent"); - assert.equal(syncs().length, 0, JSON.stringify(harness.emits)); -}); - -test("a newer backend value re-syncs once, then the echo terminates it", async () => { - const clientStorage = { - local_storage: { - "child.first": { name: "shared", sync: true }, - "child.second": { name: "shared", sync: true }, - }, - }; - const app = await setup({ clientStorage }); - app.connect(["websocket"], clientStorage); - app.flush(); - const sock = app.sockets[0]; - const syncs = () => - app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")); - const deliver = async (delta) => { - sock.handlers.get("event")({ delta }); - app.flush(); - await new Promise((resolve) => setImmediate(resolve)); - }; - - await deliver({ child: { first: "v1" } }); - assert.equal(syncs().length, 1, JSON.stringify(app.emits)); - // The backend echoes the whole update, clearing the sent entries. - await deliver({ child: { first: "v1", second: "v1" } }); - assert.equal(syncs().length, 1, JSON.stringify(app.emits)); - - // A backend value that matches no sent value overrides storage and syncs - // the siblings once more. - await deliver({ child: { second: "v2" } }); - assert.equal(app.localStorage.items.get("shared"), "v2"); - assert.equal(syncs().length, 2, JSON.stringify(app.emits)); - assert.equal( - JSON.stringify(syncs()[1].payload.vars), - JSON.stringify({ "child.first": "v2", "child.second": "v2" }), - ); - - // That update's echo is recognized: no write, no third sync. No ping-pong. - const writes = app.localStorage.writes; - await deliver({ child: { first: "v2" } }); - assert.equal(app.localStorage.writes, writes); - assert.equal(syncs().length, 2, JSON.stringify(app.emits)); -}); diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 54a8b5fa34a..4eabef315bf 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -87,10 +87,7 @@ async function createConnection( getElementById: (id) => body.children.find((node) => node.id === id) ?? null, }, - localStorage: { - getItem: (key) => saved.get(key) ?? null, - setItem: (key, value) => saved.set(key, String(value)), - }, + localStorage: { setItem: (key, value) => saved.set(key, value) }, sessionStorage, }, "socket.io-client": { default: () => socket }, diff --git a/tests/units/test_state.py b/tests/units/test_state.py index cc61b0de658..64a19cb6350 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -7539,3 +7539,58 @@ def _clean(self): assert storage.value == "fresh" assert storage._previous_dirty_vars >= {"snapshot_write", "value"} assert not storage.dirty_vars + + +async def test_shared_storage_name_syncs_siblings( + clean_registration_context: RegistrationContext, +): + """A write to a var sharing a storage name sets the synced vars with that name. + + Args: + clean_registration_context: An isolated state registry. + """ + + class SharedFirst(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + class SharedSecond(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + class SharedPlain(State): + value: str = rx.LocalStorage("", name="shared") + + class OtherName(State): + value: str = rx.LocalStorage("", name="other", sync=True) + + key = "value" + FIELD_MARKER + root = State() + first = await root.get_state(SharedFirst) + first.value = "new" + delta = await root._get_resolved_delta() + root._clean() + assert delta[SharedFirst.get_full_name()][key] == "new" + assert delta[SharedSecond.get_full_name()][key] == "new" + assert SharedPlain.get_full_name() not in delta + assert OtherName.get_full_name() not in delta + + # An unsynced var's write raises the storage event too, so it syncs them. + plain = await root.get_state(SharedPlain) + plain.value = "plain" + delta = await root._get_resolved_delta() + root._clean() + assert delta[SharedFirst.get_full_name()][key] == "plain" + assert delta[SharedSecond.get_full_name()][key] == "plain" + + # An unchanged sibling is not sent again. + first.value = "plain" + assert await root._get_resolved_delta() == { + SharedFirst.get_full_name(): {key: "plain"} + } + root._clean() + + # A name no other var shares sets nothing else. + other = await root.get_state(OtherName) + other.value = "alone" + assert await root._get_resolved_delta() == { + OtherName.get_full_name(): {key: "alone"} + } From 6a9c88cb3f6455daac02e4cf632462d07fe6e107 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 28 Oct 2026 00:01:32 +0500 Subject: [PATCH 6/8] Keep event writes to vars that share a storage name When one event writes two synced vars with the same storage name, the fan-out set each to the other's value and lost one write. Collect the event's writes first and do not set a var the event wrote. --- reflex/state.py | 35 +++++++++++++++++++++++++++-------- tests/units/test_state.py | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index f89befa1da8..80b3dd1ffdd 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2339,20 +2339,39 @@ async def _sync_shared_storage(state: BaseState) -> None: state: The root of the states to get the delta for. """ targets = _shared_storage_targets(state.get_root_state(), RegistrationContext.get()) - for (state_name, var_name), synced in targets.items(): - source = state - for name in state_name.split(".")[1:]: - if (source := source.substates.get(name)) is None: - break - if source is None or var_name not in source.dirty_vars: - continue - value = source.get_value(var_name) + writes = { + key: (source.get_value(key[1]), synced) + for key, synced in targets.items() + if (source := _loaded_substate(state, key[0])) is not None + and key[1] in source.dirty_vars + } + for value, synced in writes.values(): for target_cls, target_var in synced: + # A var this event wrote keeps its own value. + if (target_cls.get_full_name(), target_var) in writes: + continue target = await state.get_state(target_cls) if target.get_value(target_var) != value: setattr(target, target_var, value) +def _loaded_substate(root: BaseState, state_name: str) -> BaseState | None: + """Get a substate by its full name only if it is already loaded. + + Args: + root: The root state. + state_name: The full name of the substate. + + Returns: + The substate, or None if it is not loaded. + """ + state = root + for name in state_name.split(".")[1:]: + if (state := state.substates.get(name)) is None: + return None + return state + + @functools.lru_cache def _frontend_schema(root: type[BaseState], context: RegistrationContext) -> str: """Hash the frontend schema of the state tree below ``root``. diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 64a19cb6350..b7b0b874a64 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -7594,3 +7594,27 @@ class OtherName(State): assert await root._get_resolved_delta() == { OtherName.get_full_name(): {key: "alone"} } + + +async def test_shared_storage_name_keeps_event_writes( + clean_registration_context: RegistrationContext, +): + """Vars sharing a storage name that one event writes keep their own values. + + Args: + clean_registration_context: An isolated state registry. + """ + + class WrittenFirst(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + class WrittenSecond(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + root = State() + first = await root.get_state(WrittenFirst) + second = await root.get_state(WrittenSecond) + first.value = "one" + second.value = "two" + await root._get_resolved_delta() + assert (first.value, second.value) == ("one", "two") From 5d9dab026d38c41e177321bd63e06ce31a5e1223 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 28 Oct 2026 00:07:47 +0500 Subject: [PATCH 7/8] Group shared storage vars by name for the fan-out The fan-out found the vars an event wrote from a per-var map that left out vars with no synced sibling, so an unsynced write could overwrite a synced var written in the same event. Group the vars by storage name and find the written vars in each group, so a written var always keeps its value. --- reflex/state.py | 65 +++++++++++++++++++-------------------- tests/units/test_state.py | 5 ++- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 80b3dd1ffdd..9c0e78ce9e9 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1112,7 +1112,7 @@ def _init_var_dependency_dicts(cls): # change to any state invalidates every cached root. cls._to_schema.cache_clear() _frontend_schema.cache_clear() - _shared_storage_targets.cache_clear() + _shared_storage_groups.cache_clear() @classmethod def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]: @@ -2287,46 +2287,39 @@ def _deserialize( @functools.lru_cache -def _shared_storage_targets( +def _shared_storage_groups( root: type[BaseState], context: RegistrationContext -) -> dict[tuple[str, str], tuple[tuple[type[BaseState], str], ...]]: - """Map each localStorage var to the other synced vars with its storage name. +) -> tuple[tuple[tuple[type[BaseState], str, bool], ...], ...]: + """Group the localStorage vars by storage name. - Only vars whose storage name another synced var shares are included. + Only names that a synced var shares with another var are included. Args: root: The state at the top of the tree. context: The registration context holding the tree. Returns: - The synced vars to set, by the state name and var name of a var. + The state, var name and sync flag of each var, one group per name. """ # The compiler imports this module. from reflex.compiler.utils import _compile_client_storage_recursive - by_name: dict[str, list[tuple[str, str, bool]]] = {} + by_name: dict[str, list[tuple[type[BaseState], str, bool]]] = {} for state_key, options in _compile_client_storage_recursive(root)[1].items(): if name := options.get("name"): state_name, _, var_name = state_key.removesuffix(FIELD_MARKER).rpartition( "." ) by_name.setdefault(name, []).append(( - state_name, + root.get_class_substate(state_name), var_name, bool(options.get("sync")), )) - return { - (state_name, var_name): targets + return tuple( + tuple(members) for members in by_name.values() - for state_name, var_name, _ in members - if ( - targets := tuple( - (root.get_class_substate(other_state), other_var) - for other_state, other_var, sync in members - if sync and (other_state, other_var) != (state_name, var_name) - ) - ) - } + if len(members) > 1 and any(sync for _, _, sync in members) + ) async def _sync_shared_storage(state: BaseState) -> None: @@ -2338,21 +2331,25 @@ async def _sync_shared_storage(state: BaseState) -> None: Args: state: The root of the states to get the delta for. """ - targets = _shared_storage_targets(state.get_root_state(), RegistrationContext.get()) - writes = { - key: (source.get_value(key[1]), synced) - for key, synced in targets.items() - if (source := _loaded_substate(state, key[0])) is not None - and key[1] in source.dirty_vars - } - for value, synced in writes.values(): - for target_cls, target_var in synced: - # A var this event wrote keeps its own value. - if (target_cls.get_full_name(), target_var) in writes: - continue - target = await state.get_state(target_cls) - if target.get_value(target_var) != value: - setattr(target, target_var, value) + for members in _shared_storage_groups( + state.get_root_state(), RegistrationContext.get() + ): + written = { + (state_cls, var_name): source.get_value(var_name) + for state_cls, var_name, _ in members + if (source := _loaded_substate(state, state_cls.get_full_name())) + is not None + and var_name in source.dirty_vars + } + if not written: + continue + # The vars this event wrote keep their values; the others take the first. + value = next(iter(written.values())) + for state_cls, var_name, sync in members: + if sync and (state_cls, var_name) not in written: + target = await state.get_state(state_cls) + if target.get_value(var_name) != value: + setattr(target, var_name, value) def _loaded_substate(root: BaseState, state_name: str) -> BaseState | None: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index b7b0b874a64..af9929d13a5 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -7596,20 +7596,23 @@ class OtherName(State): } +@pytest.mark.parametrize("second_sync", [True, False]) async def test_shared_storage_name_keeps_event_writes( clean_registration_context: RegistrationContext, + second_sync: bool, ): """Vars sharing a storage name that one event writes keep their own values. Args: clean_registration_context: An isolated state registry. + second_sync: Whether the second var syncs. """ class WrittenFirst(State): value: str = rx.LocalStorage("", name="shared", sync=True) class WrittenSecond(State): - value: str = rx.LocalStorage("", name="shared", sync=True) + value: str = rx.LocalStorage("", name="shared", sync=second_sync) root = State() first = await root.get_state(WrittenFirst) From 52afe7277eb93948526eea5cf1ed0ee299aaaa48 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 28 Oct 2026 00:16:35 +0500 Subject: [PATCH 8/8] Sort shared storage groups by state and var name The groups came from a walk over a set of state classes, so their order, and the value that conflicting writes give the other synced vars, could differ between processes. Sort each group once when it is built. --- reflex/state.py | 7 +++++-- tests/units/test_state.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 9c0e78ce9e9..b4e20f2257b 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2315,8 +2315,10 @@ def _shared_storage_groups( var_name, bool(options.get("sync")), )) + # Sorted, so conflicting writes to one name set the other vars the same way + # in every process. return tuple( - tuple(members) + tuple(sorted(members, key=lambda m: (m[0].get_full_name(), m[1]))) for members in by_name.values() if len(members) > 1 and any(sync for _, _, sync in members) ) @@ -2343,7 +2345,8 @@ async def _sync_shared_storage(state: BaseState) -> None: } if not written: continue - # The vars this event wrote keep their values; the others take the first. + # The vars this event wrote keep their values; the others take the first + # written value in name order. value = next(iter(written.values())) for state_cls, var_name, sync in members: if sync and (state_cls, var_name) not in written: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index af9929d13a5..5016dc831b4 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -7621,3 +7621,28 @@ class WrittenSecond(State): second.value = "two" await root._get_resolved_delta() assert (first.value, second.value) == ("one", "two") + + +async def test_shared_storage_name_conflicting_writes_pick_by_name( + clean_registration_context: RegistrationContext, +): + """Conflicting writes to one storage name set the other synced vars in name order. + + Args: + clean_registration_context: An isolated state registry. + """ + + class ZWriter(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + class AWriter(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + class MSibling(State): + value: str = rx.LocalStorage("", name="shared", sync=True) + + root = State() + (await root.get_state(ZWriter)).value = "z" + (await root.get_state(AWriter)).value = "a" + await root._get_resolved_delta() + assert (await root.get_state(MSibling)).value == "a"