From 398d4b97a26ff44c0b0972557da0e8aff4b26abc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:11:38 +0000 Subject: [PATCH 01/11] Stop writing echoed browser storage values back to the browser Since #7493 the boot event's delta carries the browser storage values the tab sent, so get_delta overrides see them, as update_vars_internal's delta always did. The frontend wrote those echoes back to localStorage. When another tab stored a newer value in the meantime, the echo overwrote it and fired a storage event, so the tabs of a sync=True var kept answering each other's stale echoes: an endless ping-pong that also lost the latest value. - Record the storage values a tab sends with hydrate_and_load and update_vars_internal, and do not write a delta value back to local or session storage when it echoes one of them. Values a get_delta override or handler changed are still written. Cookies are still rewritten, which renews their max_age as before. - On a storage event, send the value stored now rather than e.newValue: a tab gets other tabs' events after its own later write too. - When the echo of a synced var crossed a newer stored value, send that value again so the tab follows the browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../news/+synced-storage-echo.bugfix.md | 1 + .../reflex_base/.templates/web/utils/state.js | 109 ++++++++-- .../test_hydration_storage.py | 194 +++++++++++++++++- 3 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 packages/reflex-base/news/+synced-storage-echo.bugfix.md diff --git a/packages/reflex-base/news/+synced-storage-echo.bugfix.md b/packages/reflex-base/news/+synced-storage-echo.bugfix.md new file mode 100644 index 00000000000..4d178051f6d --- /dev/null +++ b/packages/reflex-base/news/+synced-storage-echo.bugfix.md @@ -0,0 +1 @@ +Changing a `sync=True` `rx.LocalStorage` var while other tabs of the app are loading, or from several tabs at once, no longer starts an endless storage ping-pong between the tabs or loses the latest value. A tab no longer writes the local or session storage values it sent to the backend back to the browser when the backend echoes them, and a synced var always follows the value stored now. 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 6916df56371..72c9cc6498a 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 @@ -53,6 +53,14 @@ let backend_state_mismatch = false; // Array holding pending events to be processed. const event_queue = []; +// The event that applies browser storage values changed in another tab. +const UPDATE_VARS_INTERNAL = + "reflex___state____update_vars_internal_state.update_vars_internal"; + +// Browser storage values this tab sent to the backend, oldest first, by state +// key. The backend echoes them back, so that get_delta overrides see them. +const sentStorageValues = {}; + // Mirrors the data router's location so applyEvent can populate router_data // with the in-widget URL. In embed mode the host page's window.location is // unrelated to the Reflex route, so the backend's on_load and dynamic-route @@ -514,6 +522,7 @@ export const applyEvent = async (event, socket, navigate, params) => { // Send the event to the server. if (socket) { const routed_event = withRouterData(event, params); + recordSentStorageValues(routed_event); // Instrumentation hook (installed by reflex-otel): may add a traceparent. window.__reflex_otel?.onEventSend?.(routed_event); socket.emit("event", routed_event); @@ -702,6 +711,7 @@ export const connect = async ( // the backend as CompileVars.CONNECT_AUTH_EVENT. const bootAuth = (first) => { const boot_event = withRouterData(app.initialEvents(first)[0], params); + recordSentStorageValues(boot_event); // Instrumentation hook (installed by reflex-otel): may add a traceparent. window.__reflex_otel?.onEventSend?.(boot_event); return { event: boot_event }; @@ -893,7 +903,20 @@ export const connect = async ( on_hydrated_queue.length = 0; } } - applyClientStorageDelta(client_storage, update.delta); + const resync = applyClientStorageDelta(client_storage, update.delta); + if (resync) { + queueEvents( + [ + ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { + vars: resync, + }), + ], + socket, + false, + navigate, + params, + ).catch(reportStateUpdateError); + } } if (update.events && update.events.length > 0) { queueEvents(update.events, socket, false, navigate, params).catch( @@ -1037,10 +1060,49 @@ export const hydrateClientStorage = (client_storage) => { return {}; }; +/** + * Remember the browser storage values an event sends to the backend. + * @param event The event about to be sent. + */ +const recordSentStorageValues = (event) => { + if ( + event.name !== `${app.state_name}.hydrate_and_load` && + event.name !== `${app.state_name}.${UPDATE_VARS_INTERNAL}` + ) { + return; + } + for (const [state_key, value] of Object.entries(event.payload.vars ?? {})) { + const sent = (sentStorageValues[state_key] ??= []); + sent.push(value); + // An echo a get_delta override dropped never arrives to consume its value. + if (sent.length > 16) { + sent.shift(); + } + } +}; + +/** + * Consume the echo of a browser storage value this tab sent to the backend. + * Values sent before it are dropped too, as their echoes have passed. + * @param state_key The state key of the browser storage var. + * @param value The value the backend sent for it. + * @returns Whether the value echoes one this tab sent. + */ +const consumeStorageEcho = (state_key, value) => { + const sent = sentStorageValues[state_key]; + const index = sent ? sent.indexOf(value) : -1; + if (index !== -1) { + sent.splice(0, index + 1); + } + return index !== -1; +}; + /** * 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 The synced localStorage values to send again, as the backend echoed + * older ones, or undefined. */ const applyClientStorageDelta = (client_storage, delta) => { // find the main state and check for is_hydrated @@ -1059,34 +1121,48 @@ const applyClientStorageDelta = (client_storage, delta) => { } } // Save known client storage values to cookies and localStorage. + let resync; for (const substate in delta) { for (const key in delta[substate]) { const state_key = `${substate}.${key}`; + const value = delta[substate][key]; + // Rewriting a value this tab sent would overwrite a newer one another + // tab stored meanwhile and, for a synced var, echo it to the other tabs. + // A cookie is still rewritten, which renews its max_age. + const echo = consumeStorageEcho(state_key, value); if (client_storage.cookies && state_key in client_storage.cookies) { const cookie_options = { ...client_storage.cookies[state_key] }; const cookie_name = cookie_options.name || state_key; delete cookie_options.name; // name is not a valid cookie option - cookies.set(cookie_name, delta[substate][key], cookie_options); + cookies.set(cookie_name, value, cookie_options); + } else if (typeof window === "undefined") { + continue; } else if ( client_storage.local_storage && - state_key in client_storage.local_storage && - typeof window !== "undefined" + state_key in client_storage.local_storage ) { const options = client_storage.local_storage[state_key]; - localStorage.setItem(options.name || state_key, delta[substate][key]); + const name = options.name || state_key; + if (!echo) { + localStorage.setItem(name, value); + } else if (options.sync) { + // Another tab may have stored a newer value after this tab sent it. + const stored = localStorage.getItem(name); + if (stored !== value) { + (resync ??= {})[state_key] = stored; + } + } } else if ( + !echo && client_storage.session_storage && - state_key in client_storage.session_storage && - typeof window !== "undefined" + state_key in client_storage.session_storage ) { const session_options = client_storage.session_storage[state_key]; - sessionStorage.setItem( - session_options.name || state_key, - delta[substate][key], - ); + sessionStorage.setItem(session_options.name || state_key, value); } } } + return resync; }; /** @@ -1267,11 +1343,12 @@ export const useEventLoop = ( const handleStorage = (e) => { if (storage_to_state_map[e.key]) { const vars = {}; - vars[storage_to_state_map[e.key]] = e.newValue; - const event = ReflexEvent( - `${app.state_name}.reflex___state____update_vars_internal_state.update_vars_internal`, - { vars: vars }, - ); + // A tab gets the events of other tabs' writes after its own newer + // write too, so send the value stored now rather than e.newValue. + vars[storage_to_state_map[e.key]] = localStorage.getItem(e.key); + const event = ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { + vars: vars, + }); addEvents([event], e); } }; diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 69bcd1830ec..a00a6893d1f 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -1,9 +1,10 @@ """Browser regressions for client-storage writes during hydration and assigned defaults.""" -from collections.abc import Generator +import re +from collections.abc import Callable, Generator import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Page, WebSocketRoute, expect from reflex.testing import AppHarness, AppHarnessProd @@ -101,6 +102,31 @@ def get_component(cls, initial: str) -> rx.Component: rx.button("Change", on_click=cls.change, id="change-pref"), ) + class SyncState(rx.State): + value: str = rx.LocalStorage("", name="hydrate-sync", sync=True) + + @rx.event + def set_value(self, value: str): + """Store a value that other tabs pick up. + + Args: + value: The value to store. + """ + self.value = value + + def synced(): + """Display a browser storage var synced across tabs. + + Returns: + The page component. + """ + return rx.box( + rx.text(SyncState.value, id="sync-value"), + rx.button("Old", on_click=SyncState.set_value("old"), id="set-old"), + rx.button("New", on_click=SyncState.set_value("new"), id="set-new"), + rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"), + ) + def index(): """Display hydration and normalized storage values. @@ -133,6 +159,7 @@ def assigned(): app.add_page(index) app.add_page(index, route="/loaded", on_load=StorageState.load) app.add_page(assigned, route="/assigned") + app.add_page(synced, route="/synced") @pytest.fixture(scope="module", params=[AppHarness, AppHarnessProd]) @@ -262,3 +289,166 @@ def test_assigned_storage_default_persists( for var in VARS: expect(page.locator(f"#assigned-{var}")).to_have_text("changed") assert page.evaluate("sessionStorage.getItem('assigned-session')") == "changed" + + +# Records every localStorage write of the synced var. +RECORD_SYNC_WRITES = """ +window.syncWrites = []; +const setItem = Storage.prototype.setItem; +Storage.prototype.setItem = function (key, value) { + if (key === "hydrate-sync") { + window.syncWrites.push(value); + } + return setItem.call(this, key, value); +}; +""" + + +def wait_until(page: Page, condition: Callable[[], bool]): + """Wait for a condition while Playwright keeps dispatching websocket messages. + + Args: + page: The page whose event loop dispatches the messages. + condition: The condition to wait for. + + Raises: + TimeoutError: If the condition does not hold within 10 seconds. + """ + for _ in range(100): + if condition(): + return + page.wait_for_timeout(100) + msg = "Condition not met within 10 seconds." + raise TimeoutError(msg) + + +def test_synced_storage_echo_keeps_newer_value( + hydration_storage_app: AppHarness, page: Page +): + """A booting tab does not write back a synced value another tab changed meanwhile. + + The second tab boots with the stored value while its websocket replies are + held back; the first tab then stores a newer value. Once the replies arrive, + the second tab must not write the value it booted with over the newer one, + which would also echo it back to the first tab through a storage event. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + assert hydration_storage_app.frontend_url is not None + url = f"{hydration_storage_app.frontend_url.rstrip('/')}/synced" + page.goto(url) + expect(page.locator("#hydrated")).to_have_text("true") + page.locator("#set-old").click() + page.wait_for_function("localStorage.getItem('hydrate-sync') === 'old'") + + sent: list[str] = [] + held: list[str | bytes] = [] + routes: list[WebSocketRoute] = [] + + def hold_replies(route: WebSocketRoute): + """Forward the page's messages; hold back the server's event replies. + + Args: + route: The intercepted websocket. + """ + server = route.connect_to_server() + routes.append(route) + + def to_server(message: str | bytes): + sent.append(str(message)) + server.send(message) + + def to_page(message: str | bytes): + if routes and '"event"' in str(message): + held.append(message) + else: + route.send(message) + + route.on_message(to_server) + server.on_message(to_page) + + other = page.context.new_page() + other.add_init_script(RECORD_SYNC_WRITES) + other.route_web_socket(re.compile(r".*/_event"), hold_replies) + other.goto(url) + # The boot event is sent once the page's effects, storage listener included, ran. + wait_until( + other, + lambda: any("hydrate_and_load" in m and '"old"' in m for m in sent), + ) + + page.locator("#set-new").click() + page.wait_for_function("localStorage.getItem('hydrate-sync') === 'new'") + wait_until( + other, + lambda: any("update_vars_internal" in m and '"new"' in m for m in sent), + ) + + route = routes.pop() + for message in held: + route.send(message) + expect(other.locator("#hydrated")).to_have_text("true") + expect(other.locator("#sync-value")).to_have_text("new") + expect(page.locator("#sync-value")).to_have_text("new") + other.wait_for_timeout(500) + assert "old" not in other.evaluate("window.syncWrites") + assert page.evaluate("localStorage.getItem('hydrate-sync')") == "new" + + +def test_synced_storage_event_sends_stored_value( + hydration_storage_app: AppHarness, page: Page +): + """A late storage event syncs the value stored now, not the value in the event. + + A tab can get the event of another tab's write after a later write, so the + event's ``newValue`` may already be stale. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + assert hydration_storage_app.frontend_url is not None + page.add_init_script(RECORD_SYNC_WRITES) + page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") + expect(page.locator("#hydrated")).to_have_text("true") + page.evaluate("""() => { + localStorage.setItem('hydrate-sync', 'stored'); + window.syncWrites.length = 0; + window.dispatchEvent(new StorageEvent('storage', { + key: 'hydrate-sync', oldValue: '', newValue: 'late', + })); + }""") + expect(page.locator("#sync-value")).to_have_text("stored") + page.wait_for_timeout(500) + assert page.evaluate("window.syncWrites") == [] + assert page.evaluate("localStorage.getItem('hydrate-sync')") == "stored" + + +def test_synced_storage_resyncs_after_crossed_echo( + hydration_storage_app: AppHarness, page: Page +): + """A synced var whose echo crossed a newer stored value sends that value again. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + assert hydration_storage_app.frontend_url is not None + page.add_init_script(RECORD_SYNC_WRITES) + page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") + expect(page.locator("#hydrated")).to_have_text("true") + # The event sends "sent"; another tab stores "newer" before its echo arrives. + page.evaluate("""() => { + localStorage.setItem('hydrate-sync', 'sent'); + window.syncWrites.length = 0; + window.dispatchEvent(new StorageEvent('storage', { + key: 'hydrate-sync', oldValue: '', newValue: 'sent', + })); + localStorage.setItem('hydrate-sync', 'newer'); + }""") + expect(page.locator("#sync-value")).to_have_text("newer") + page.wait_for_timeout(500) + assert page.evaluate("window.syncWrites") == ["newer"] + assert page.evaluate("localStorage.getItem('hydrate-sync')") == "newer" From d14c025bdf034be1e4526c76d283661287e41cec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:22:39 +0000 Subject: [PATCH 02/11] Handle a hydrate without payload and keep in-flight echoes longer ReflexEvent leaves out an empty payload, so a reconnect hydrate with no stored values has none and recording its values threw before the socket reconnected. The state.js unit test now builds that event like ReflexEvent. Keep up to 256 sent values per key instead of 16, so a synced var written many times within one round trip does not lose the record of an echo that is still in flight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../src/reflex_base/.templates/web/utils/state.js | 10 +++++++--- tests/units/compiler/state_js.test.mjs | 11 +++++++---- 2 files changed, 14 insertions(+), 7 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 72c9cc6498a..ca733445909 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 @@ -60,6 +60,10 @@ const UPDATE_VARS_INTERNAL = // Browser storage values this tab sent to the backend, oldest first, by state // key. The backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; +// Bounds the values kept for a key whose echoes never match, as when a +// get_delta override replaces them. It exceeds the values of one key in flight +// at once: a synced var written at 60 Hz over a one second round trip. +const MAX_SENT_STORAGE_VALUES = 256; // Mirrors the data router's location so applyEvent can populate router_data // with the in-widget URL. In embed mode the host page's window.location is @@ -1071,11 +1075,11 @@ const recordSentStorageValues = (event) => { ) { return; } - for (const [state_key, value] of Object.entries(event.payload.vars ?? {})) { + // ReflexEvent leaves out an empty payload. + for (const [state_key, value] of Object.entries(event.payload?.vars ?? {})) { const sent = (sentStorageValues[state_key] ??= []); sent.push(value); - // An echo a get_delta override dropped never arrives to consume its value. - if (sent.length > 16) { + if (sent.length > MAX_SENT_STORAGE_VALUES) { sent.shift(); } } diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 9efcc61472b..e1214a109ff 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -113,11 +113,14 @@ async function setup({ app: { initialEvents(first) { firstHydrates.push(first); + // Like ReflexEvent, a later hydrate without stored values has no payload. return [ - { - name: "root.hydrate_and_load", - payload: first ? { hashes: ["defaults"] } : {}, - }, + first + ? { + name: "root.hydrate_and_load", + payload: { hashes: ["defaults"] }, + } + : { name: "root.hydrate_and_load" }, ]; }, initialState: stateful ? { root: {}, child: {} } : {}, From 5994b91413312db3e920632f34f719088d47f48c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:34:59 +0000 Subject: [PATCH 03/11] Retire a sent storage value when its reply was changed The backend answers the browser storage values a tab sends in order, so a reply that matches none of them, because a handler or get_delta override changed it, answers the oldest one. Retire that value instead of keeping it, or a later handler that sets the same value is taken for its echo: the browser skipped the write and, for a synced var, sent the replaced value back over the handler's change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex_base/.templates/web/utils/state.js | 17 ++++--- .../test_hydration_storage.py | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 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 ca733445909..d1a69664bfd 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 @@ -60,8 +60,8 @@ const UPDATE_VARS_INTERNAL = // Browser storage values this tab sent to the backend, oldest first, by state // key. The backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; -// Bounds the values kept for a key whose echoes never match, as when a -// get_delta override replaces them. It exceeds the values of one key in flight +// Bounds the values kept for a key whose replies never arrive, as when a +// get_delta override drops them. It exceeds the values of one key in flight // at once: a synced var written at 60 Hz over a one second round trip. const MAX_SENT_STORAGE_VALUES = 256; @@ -1086,18 +1086,21 @@ const recordSentStorageValues = (event) => { }; /** - * Consume the echo of a browser storage value this tab sent to the backend. - * Values sent before it are dropped too, as their echoes have passed. + * Consume the reply to a browser storage value this tab sent to the backend. + * The backend answers the values in the order they were sent, so a reply that + * matches none of them, changed by a handler or get_delta override, answers + * the oldest. Values sent before a matching one were answered already. * @param state_key The state key of the browser storage var. * @param value The value the backend sent for it. * @returns Whether the value echoes one this tab sent. */ const consumeStorageEcho = (state_key, value) => { const sent = sentStorageValues[state_key]; - const index = sent ? sent.indexOf(value) : -1; - if (index !== -1) { - sent.splice(0, index + 1); + if (!sent?.length) { + return false; } + const index = sent.indexOf(value); + sent.splice(0, index === -1 ? 1 : index + 1); return index !== -1; }; diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index a00a6893d1f..eed7bbc588b 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -114,6 +114,29 @@ def set_value(self, value: str): """ self.value = value + class ReplaceState(rx.State): + value: str = rx.LocalStorage("", name="hydrate-replace", sync=True) + _keep: bool = False + + @rx.event + def keep_sent(self): + """Store the value the override replaced at boot, now unreplaced.""" + self._keep = True + self.value = "sent" + + @rx.state._override_base_method + def get_delta(self): + """Replace the stored value until a handler stores it on purpose. + + Returns: + The delta, with the value replaced. + """ + delta = super().get_delta() + subdelta = delta.get(self.get_full_name(), {}) + if not self._keep and subdelta.get("value" + FIELD_MARKER) == "sent": + subdelta["value" + FIELD_MARKER] = "replaced" + return delta + def synced(): """Display a browser storage var synced across tabs. @@ -124,6 +147,8 @@ def synced(): rx.text(SyncState.value, id="sync-value"), rx.button("Old", on_click=SyncState.set_value("old"), id="set-old"), rx.button("New", on_click=SyncState.set_value("new"), id="set-new"), + rx.text(ReplaceState.value, id="replace-value"), + rx.button("Keep", on_click=ReplaceState.keep_sent, id="keep-sent"), rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"), ) @@ -452,3 +477,28 @@ def test_synced_storage_resyncs_after_crossed_echo( page.wait_for_timeout(500) assert page.evaluate("window.syncWrites") == ["newer"] assert page.evaluate("localStorage.getItem('hydrate-sync')") == "newer" + + +def test_replaced_storage_echo_does_not_hide_later_change( + hydration_storage_app: AppHarness, page: Page +): + """A value sent at boot whose echo was replaced is written when a handler sets it. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + assert hydration_storage_app.frontend_url is not None + page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") + expect(page.locator("#hydrated")).to_have_text("true") + page.evaluate("localStorage.setItem('hydrate-replace', 'sent')") + page.reload() + expect(page.locator("#hydrated")).to_have_text("true") + expect(page.locator("#replace-value")).to_have_text("replaced") + page.wait_for_function("localStorage.getItem('hydrate-replace') === 'replaced'") + + page.locator("#keep-sent").click() + expect(page.locator("#replace-value")).to_have_text("sent") + page.wait_for_function("localStorage.getItem('hydrate-replace') === 'sent'") + page.wait_for_timeout(500) + expect(page.locator("#replace-value")).to_have_text("sent") From ff23ef3b9fffacddf4194025c6e3e74dda2fa1ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:01:07 +0000 Subject: [PATCH 04/11] Skip only echoes another tab overtook, instead of retiring sent values Retiring the oldest sent value whenever a reply did not match brought the cross-tab storm back: a reply to a handler or on_load write retired the record of an echo still in flight, which was then written over the other tabs' newer value. The verifier's dev-reload scenario stormed 2 times in 6. An echo is stale only when another tab wrote the var after this tab sent the value, and every such write reaches this tab as a storage event. Count those per localStorage var, record the count with each value sent, and skip writing an echo only when the count has grown since. Any other echo is written: storage still holds that value, or this tab's own later write is what the backend answered. A later handler that sets a value whose earlier reply was changed is written again, and the resync is no longer needed: the other tab's write already sends this tab the value stored now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex_base/.templates/web/utils/state.js | 92 +++++++------------ .../test_hydration_storage.py | 24 +++-- 2 files changed, 49 insertions(+), 67 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 d1a69664bfd..e8b7f254f6c 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 @@ -57,11 +57,14 @@ const event_queue = []; const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; +// Writes by other tabs to each localStorage var, counted from storage events. +const otherTabWrites = {}; // Browser storage values this tab sent to the backend, oldest first, by state -// key. The backend echoes them back, so that get_delta overrides see them. +// key, with the other tabs' writes to the var counted when each was sent. The +// backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; -// Bounds the values kept for a key whose replies never arrive, as when a -// get_delta override drops them. It exceeds the values of one key in flight +// Bounds the values kept for a key whose replies never match, as when a +// get_delta override changes them. It exceeds the values of one key in flight // at once: a synced var written at 60 Hz over a one second round trip. const MAX_SENT_STORAGE_VALUES = 256; @@ -907,20 +910,7 @@ export const connect = async ( on_hydrated_queue.length = 0; } } - const resync = applyClientStorageDelta(client_storage, update.delta); - if (resync) { - queueEvents( - [ - ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { - vars: resync, - }), - ], - socket, - false, - navigate, - params, - ).catch(reportStateUpdateError); - } + applyClientStorageDelta(client_storage, update.delta); } if (update.events && update.events.length > 0) { queueEvents(update.events, socket, false, navigate, params).catch( @@ -1078,7 +1068,7 @@ const recordSentStorageValues = (event) => { // ReflexEvent leaves out an empty payload. for (const [state_key, value] of Object.entries(event.payload?.vars ?? {})) { const sent = (sentStorageValues[state_key] ??= []); - sent.push(value); + sent.push({ value, other_writes: otherTabWrites[state_key] ?? 0 }); if (sent.length > MAX_SENT_STORAGE_VALUES) { sent.shift(); } @@ -1086,30 +1076,26 @@ const recordSentStorageValues = (event) => { }; /** - * Consume the reply to a browser storage value this tab sent to the backend. - * The backend answers the values in the order they were sent, so a reply that - * matches none of them, changed by a handler or get_delta override, answers - * the oldest. Values sent before a matching one were answered already. + * Whether a value echoes one this tab sent before another tab replaced it. + * Consumes the echoed value and the values sent before it, whose echoes passed. * @param state_key The state key of the browser storage var. * @param value The value the backend sent for it. - * @returns Whether the value echoes one this tab sent. + * @returns Whether writing the value would overwrite another tab's newer one. */ -const consumeStorageEcho = (state_key, value) => { +const isStaleStorageEcho = (state_key, value) => { const sent = sentStorageValues[state_key]; - if (!sent?.length) { + const index = sent ? sent.findIndex((entry) => entry.value === value) : -1; + if (index === -1) { return false; } - const index = sent.indexOf(value); - sent.splice(0, index === -1 ? 1 : index + 1); - return index !== -1; + const { other_writes } = sent.splice(0, index + 1)[index]; + return (otherTabWrites[state_key] ?? 0) > other_writes; }; /** * 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 The synced localStorage values to send again, as the backend echoed - * older ones, or undefined. */ const applyClientStorageDelta = (client_storage, delta) => { // find the main state and check for is_hydrated @@ -1128,48 +1114,38 @@ const applyClientStorageDelta = (client_storage, delta) => { } } // Save known client storage values to cookies and localStorage. - let resync; for (const substate in delta) { for (const key in delta[substate]) { const state_key = `${substate}.${key}`; const value = delta[substate][key]; - // Rewriting a value this tab sent would overwrite a newer one another - // tab stored meanwhile and, for a synced var, echo it to the other tabs. - // A cookie is still rewritten, which renews its max_age. - const echo = consumeStorageEcho(state_key, value); + // The backend echoes the values a tab sends. Writing back one that + // another tab replaced since would overwrite the newer value and, for a + // synced var, make the other tabs answer with theirs, over and over. + const stale = isStaleStorageEcho(state_key, value); if (client_storage.cookies && state_key in client_storage.cookies) { const cookie_options = { ...client_storage.cookies[state_key] }; const cookie_name = cookie_options.name || state_key; delete cookie_options.name; // name is not a valid cookie option cookies.set(cookie_name, value, cookie_options); - } else if (typeof window === "undefined") { - continue; } else if ( client_storage.local_storage && - state_key in client_storage.local_storage + state_key in client_storage.local_storage && + typeof window !== "undefined" ) { - const options = client_storage.local_storage[state_key]; - const name = options.name || state_key; - if (!echo) { - localStorage.setItem(name, value); - } else if (options.sync) { - // Another tab may have stored a newer value after this tab sent it. - const stored = localStorage.getItem(name); - if (stored !== value) { - (resync ??= {})[state_key] = stored; - } + if (!stale) { + const options = client_storage.local_storage[state_key]; + localStorage.setItem(options.name || state_key, value); } } else if ( - !echo && client_storage.session_storage && - state_key in client_storage.session_storage + state_key in client_storage.session_storage && + typeof window !== "undefined" ) { const session_options = client_storage.session_storage[state_key]; sessionStorage.setItem(session_options.name || state_key, value); } } } - return resync; }; /** @@ -1339,20 +1315,22 @@ export const useEventLoop = ( if (client_storage.local_storage && typeof window !== "undefined") { for (const state_key in client_storage.local_storage) { const options = client_storage.local_storage[state_key]; - if (options.sync) { - const local_storage_value_key = options.name || state_key; - storage_to_state_map[local_storage_value_key] = state_key; - } + storage_to_state_map[options.name || state_key] = state_key; } } // e is StorageEvent const handleStorage = (e) => { - if (storage_to_state_map[e.key]) { + const state_key = storage_to_state_map[e.key]; + if (state_key === undefined || e.storageArea !== localStorage) { + return; + } + otherTabWrites[state_key] = (otherTabWrites[state_key] ?? 0) + 1; + if (client_storage.local_storage[state_key].sync) { const vars = {}; // A tab gets the events of other tabs' writes after its own newer // write too, so send the value stored now rather than e.newValue. - vars[storage_to_state_map[e.key]] = localStorage.getItem(e.key); + vars[state_key] = localStorage.getItem(e.key); const event = ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { vars: vars, }); diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index eed7bbc588b..621903864c7 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -443,18 +443,19 @@ def test_synced_storage_event_sends_stored_value( window.syncWrites.length = 0; window.dispatchEvent(new StorageEvent('storage', { key: 'hydrate-sync', oldValue: '', newValue: 'late', + storageArea: localStorage, })); }""") expect(page.locator("#sync-value")).to_have_text("stored") page.wait_for_timeout(500) - assert page.evaluate("window.syncWrites") == [] + assert "late" not in page.evaluate("window.syncWrites") assert page.evaluate("localStorage.getItem('hydrate-sync')") == "stored" -def test_synced_storage_resyncs_after_crossed_echo( +def test_synced_storage_echo_crossed_by_another_tab_is_not_written( hydration_storage_app: AppHarness, page: Page ): - """A synced var whose echo crossed a newer stored value sends that value again. + """An echo of a value another tab replaced after it was sent is not written back. Args: hydration_storage_app: The running app. @@ -464,18 +465,21 @@ def test_synced_storage_resyncs_after_crossed_echo( page.add_init_script(RECORD_SYNC_WRITES) page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") expect(page.locator("#hydrated")).to_have_text("true") - # The event sends "sent"; another tab stores "newer" before its echo arrives. + # Another tab stores "sent", then "newer" before the echo of "sent" arrives. page.evaluate("""() => { - localStorage.setItem('hydrate-sync', 'sent'); + const otherTabStores = (value) => { + localStorage.setItem('hydrate-sync', value); + window.dispatchEvent(new StorageEvent('storage', { + key: 'hydrate-sync', newValue: value, storageArea: localStorage, + })); + }; + otherTabStores('sent'); + otherTabStores('newer'); window.syncWrites.length = 0; - window.dispatchEvent(new StorageEvent('storage', { - key: 'hydrate-sync', oldValue: '', newValue: 'sent', - })); - localStorage.setItem('hydrate-sync', 'newer'); }""") expect(page.locator("#sync-value")).to_have_text("newer") page.wait_for_timeout(500) - assert page.evaluate("window.syncWrites") == ["newer"] + assert "sent" not in page.evaluate("window.syncWrites") assert page.evaluate("localStorage.getItem('hydrate-sync')") == "newer" From d03ddb10735e366c8d27219ea8bab13044770295 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:20:57 +0000 Subject: [PATCH 05/11] Write an echo only over a value this tab stored after sending it The count of other tabs' writes learned of a newer write only from its storage event, which can reach a tab after the echo of its older value: the tab then wrote the old value over the new one. Decide from localStorage instead. An echoed value is written only when the value stored now is one this tab wrote after sending it, so a newer value from another tab is kept whether or not its storage event arrived yet. The storage event map is back to synced vars only, as on main, so a non-synced var sharing a synced var's storage name no longer hides it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../news/+synced-storage-echo.bugfix.md | 2 +- .../reflex_base/.templates/web/utils/state.js | 54 +++--- .../test_hydration_storage.py | 170 +++++++++++++----- 3 files changed, 148 insertions(+), 78 deletions(-) diff --git a/packages/reflex-base/news/+synced-storage-echo.bugfix.md b/packages/reflex-base/news/+synced-storage-echo.bugfix.md index 4d178051f6d..7bc41deeb94 100644 --- a/packages/reflex-base/news/+synced-storage-echo.bugfix.md +++ b/packages/reflex-base/news/+synced-storage-echo.bugfix.md @@ -1 +1 @@ -Changing a `sync=True` `rx.LocalStorage` var while other tabs of the app are loading, or from several tabs at once, no longer starts an endless storage ping-pong between the tabs or loses the latest value. A tab no longer writes the local or session storage values it sent to the backend back to the browser when the backend echoes them, and a synced var always follows the value stored now. +`sync=True` `rx.LocalStorage` vars no longer bounce between open tabs forever when the value changes while other tabs are loading, or in several tabs at once: a tab no longer writes a value it sent to the backend back over a newer one another tab stored meanwhile. 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 e8b7f254f6c..7646a6d2f29 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 @@ -57,10 +57,8 @@ const event_queue = []; const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; -// Writes by other tabs to each localStorage var, counted from storage events. -const otherTabWrites = {}; // Browser storage values this tab sent to the backend, oldest first, by state -// key, with the other tabs' writes to the var counted when each was sent. The +// key, each with the localStorage value this tab wrote after sending it. The // backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; // Bounds the values kept for a key whose replies never match, as when a @@ -1068,7 +1066,7 @@ const recordSentStorageValues = (event) => { // ReflexEvent leaves out an empty payload. for (const [state_key, value] of Object.entries(event.payload?.vars ?? {})) { const sent = (sentStorageValues[state_key] ??= []); - sent.push({ value, other_writes: otherTabWrites[state_key] ?? 0 }); + sent.push({ value }); if (sent.length > MAX_SENT_STORAGE_VALUES) { sent.shift(); } @@ -1076,20 +1074,16 @@ const recordSentStorageValues = (event) => { }; /** - * Whether a value echoes one this tab sent before another tab replaced it. - * Consumes the echoed value and the values sent before it, whose echoes passed. + * Take the sent value a backend value echoes, with the values sent before it. * @param state_key The state key of the browser storage var. * @param value The value the backend sent for it. - * @returns Whether writing the value would overwrite another tab's newer one. + * @returns The entry of the sent value, or undefined if the value is no echo. */ -const isStaleStorageEcho = (state_key, value) => { +const takeSentStorageValue = (state_key, value) => { const sent = sentStorageValues[state_key]; const index = sent ? sent.findIndex((entry) => entry.value === value) : -1; - if (index === -1) { - return false; - } - const { other_writes } = sent.splice(0, index + 1)[index]; - return (otherTabWrites[state_key] ?? 0) > other_writes; + // The values sent before it were echoed already, or replaced on the way. + return index === -1 ? undefined : sent.splice(0, index + 1)[index]; }; /** @@ -1118,10 +1112,7 @@ const applyClientStorageDelta = (client_storage, delta) => { for (const key in delta[substate]) { const state_key = `${substate}.${key}`; const value = delta[substate][key]; - // The backend echoes the values a tab sends. Writing back one that - // another tab replaced since would overwrite the newer value and, for a - // synced var, make the other tabs answer with theirs, over and over. - const stale = isStaleStorageEcho(state_key, value); + const echo = takeSentStorageValue(state_key, value); if (client_storage.cookies && state_key in client_storage.cookies) { const cookie_options = { ...client_storage.cookies[state_key] }; const cookie_name = cookie_options.name || state_key; @@ -1132,9 +1123,18 @@ const applyClientStorageDelta = (client_storage, delta) => { state_key in client_storage.local_storage && typeof window !== "undefined" ) { - if (!stale) { - const options = client_storage.local_storage[state_key]; - localStorage.setItem(options.name || state_key, value); + const options = client_storage.local_storage[state_key]; + const name = options.name || state_key; + // The backend echoes the values a tab sends. Unless this tab wrote the + // value stored now after sending, it is the sent value or another + // tab's newer one: writing the echo back would overwrite the newer one + // and, for a synced var, make the other tabs answer with theirs, over + // and over. + if (!echo || localStorage.getItem(name) === echo.written) { + localStorage.setItem(name, value); + for (const entry of sentStorageValues[state_key] ?? []) { + entry.written = value; + } } } else if ( client_storage.session_storage && @@ -1315,22 +1315,20 @@ export const useEventLoop = ( if (client_storage.local_storage && typeof window !== "undefined") { for (const state_key in client_storage.local_storage) { const options = client_storage.local_storage[state_key]; - storage_to_state_map[options.name || state_key] = state_key; + if (options.sync) { + const local_storage_value_key = options.name || state_key; + storage_to_state_map[local_storage_value_key] = state_key; + } } } // e is StorageEvent const handleStorage = (e) => { - const state_key = storage_to_state_map[e.key]; - if (state_key === undefined || e.storageArea !== localStorage) { - return; - } - otherTabWrites[state_key] = (otherTabWrites[state_key] ?? 0) + 1; - if (client_storage.local_storage[state_key].sync) { + if (storage_to_state_map[e.key]) { const vars = {}; // A tab gets the events of other tabs' writes after its own newer // write too, so send the value stored now rather than e.newValue. - vars[state_key] = localStorage.getItem(e.key); + vars[storage_to_state_map[e.key]] = localStorage.getItem(e.key); const event = ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { vars: vars, }); diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 621903864c7..b2e07f7d1c6 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -328,6 +328,18 @@ def test_assigned_storage_default_persists( }; """ +# Stores a synced value as another tab would, optionally without its storage event. +OTHER_TAB_STORES = """ +window.otherTabStores = (value, notify = true) => { + localStorage.setItem('hydrate-sync', value); + if (notify) { + window.dispatchEvent(new StorageEvent('storage', { + key: 'hydrate-sync', newValue: value, storageArea: localStorage, + })); + } +}; +""" + def wait_until(page: Page, condition: Callable[[], bool]): """Wait for a condition while Playwright keeps dispatching websocket messages. @@ -347,6 +359,68 @@ def wait_until(page: Page, condition: Callable[[], bool]): raise TimeoutError(msg) +class EventSocket: + """Records a page's event messages and holds back the server's replies on request.""" + + def __init__(self, page: Page, hold: bool = False): + """Route the page's event websocket through this object. + + Args: + page: The page whose websocket to route. + hold: Whether to hold back the replies from the start. + """ + self.page = page + self.sent: list[str] = [] + self.held: list[str | bytes] | None = [] if hold else None + self.route: WebSocketRoute | None = None + page.route_web_socket(re.compile(r".*/_event"), self._connect) + + def _connect(self, route: WebSocketRoute): + """Forward the page's messages and the server's, except held replies. + + Args: + route: The intercepted websocket. + """ + server = route.connect_to_server() + self.route = route + + def to_server(message: str | bytes): + self.sent.append(str(message)) + server.send(message) + + def to_page(message: str | bytes): + if self.held is not None and '"event"' in str(message): + self.held.append(message) + else: + route.send(message) + + route.on_message(to_server) + server.on_message(to_page) + + def wait_sent(self, *parts: str): + """Wait until the page sent a message containing all the parts. + + Args: + parts: The substrings of the message. + """ + wait_until( + self.page, + lambda: any(all(part in m for part in parts) for m in self.sent), + ) + + def hold(self): + """Hold back the server's replies from now on.""" + self.held = [] + + def release(self): + """Deliver the held replies in order and stop holding them back.""" + assert self.route is not None + assert self.held is not None + held, self.held = self.held, None + for message in held: + self.route.send(message) + + def test_synced_storage_echo_keeps_newer_value( hydration_storage_app: AppHarness, page: Page ): @@ -368,52 +442,18 @@ def test_synced_storage_echo_keeps_newer_value( page.locator("#set-old").click() page.wait_for_function("localStorage.getItem('hydrate-sync') === 'old'") - sent: list[str] = [] - held: list[str | bytes] = [] - routes: list[WebSocketRoute] = [] - - def hold_replies(route: WebSocketRoute): - """Forward the page's messages; hold back the server's event replies. - - Args: - route: The intercepted websocket. - """ - server = route.connect_to_server() - routes.append(route) - - def to_server(message: str | bytes): - sent.append(str(message)) - server.send(message) - - def to_page(message: str | bytes): - if routes and '"event"' in str(message): - held.append(message) - else: - route.send(message) - - route.on_message(to_server) - server.on_message(to_page) - other = page.context.new_page() other.add_init_script(RECORD_SYNC_WRITES) - other.route_web_socket(re.compile(r".*/_event"), hold_replies) + socket = EventSocket(other, hold=True) other.goto(url) # The boot event is sent once the page's effects, storage listener included, ran. - wait_until( - other, - lambda: any("hydrate_and_load" in m and '"old"' in m for m in sent), - ) + socket.wait_sent("hydrate_and_load", '"old"') page.locator("#set-new").click() page.wait_for_function("localStorage.getItem('hydrate-sync') === 'new'") - wait_until( - other, - lambda: any("update_vars_internal" in m and '"new"' in m for m in sent), - ) + socket.wait_sent("update_vars_internal", '"new"') - route = routes.pop() - for message in held: - route.send(message) + socket.release() expect(other.locator("#hydrated")).to_have_text("true") expect(other.locator("#sync-value")).to_have_text("new") expect(page.locator("#sync-value")).to_have_text("new") @@ -452,37 +492,69 @@ def test_synced_storage_event_sends_stored_value( assert page.evaluate("localStorage.getItem('hydrate-sync')") == "stored" +@pytest.mark.parametrize("notified", [True, False]) def test_synced_storage_echo_crossed_by_another_tab_is_not_written( - hydration_storage_app: AppHarness, page: Page + hydration_storage_app: AppHarness, page: Page, notified: bool ): """An echo of a value another tab replaced after it was sent is not written back. + The tab may get the echo before the storage event of the newer value. + Args: hydration_storage_app: The running app. page: A fresh browser page. + notified: Whether the tab got the newer value's storage event before the echo. """ assert hydration_storage_app.frontend_url is not None page.add_init_script(RECORD_SYNC_WRITES) + page.add_init_script(OTHER_TAB_STORES) page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") expect(page.locator("#hydrated")).to_have_text("true") # Another tab stores "sent", then "newer" before the echo of "sent" arrives. - page.evaluate("""() => { - const otherTabStores = (value) => { - localStorage.setItem('hydrate-sync', value); - window.dispatchEvent(new StorageEvent('storage', { - key: 'hydrate-sync', newValue: value, storageArea: localStorage, - })); - }; - otherTabStores('sent'); - otherTabStores('newer'); + page.evaluate(f"""() => {{ + window.otherTabStores('sent'); + window.otherTabStores('newer', {str(notified).lower()}); window.syncWrites.length = 0; - }""") + }}""") + if not notified: + expect(page.locator("#sync-value")).to_have_text("sent") + page.evaluate("window.otherTabStores('newer')") expect(page.locator("#sync-value")).to_have_text("newer") page.wait_for_timeout(500) assert "sent" not in page.evaluate("window.syncWrites") assert page.evaluate("localStorage.getItem('hydrate-sync')") == "newer" +def test_synced_storage_echo_after_own_write_is_written( + hydration_storage_app: AppHarness, page: Page +): + """An echo is written over a value the tab itself stored after sending it. + + The backend applies a tab's events in order, so the echo of a value sent + after a handler's event is newer than the value of the handler's reply. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + assert hydration_storage_app.frontend_url is not None + page.add_init_script(OTHER_TAB_STORES) + socket = EventSocket(page) + page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") + expect(page.locator("#hydrated")).to_have_text("true") + + socket.hold() + page.locator("#set-old").click() + socket.wait_sent("set_value", '"old"') + page.evaluate("window.otherTabStores('sent')") + socket.wait_sent("update_vars_internal", '"sent"') + + socket.release() + expect(page.locator("#sync-value")).to_have_text("sent") + page.wait_for_timeout(500) + assert page.evaluate("localStorage.getItem('hydrate-sync')") == "sent" + + def test_replaced_storage_echo_does_not_hide_later_change( hydration_storage_app: AppHarness, page: Page ): From c38383dc8aa14c8747ce98791e7146306daeb0be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:41:44 +0000 Subject: [PATCH 06/11] Sync only localStorage storage events Session storage changes in same-origin frames raise storage events too. A key matching a synced var's name made the tab send its unchanged localStorage value (on main, the session storage value). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex-base/src/reflex_base/.templates/web/utils/state.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7646a6d2f29..972fa40df4c 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 @@ -1324,7 +1324,8 @@ export const useEventLoop = ( // e is StorageEvent const handleStorage = (e) => { - if (storage_to_state_map[e.key]) { + // Session storage changes in same-origin frames raise storage events too. + if (e.storageArea === localStorage && storage_to_state_map[e.key]) { const vars = {}; // A tab gets the events of other tabs' writes after its own newer // write too, so send the value stored now rather than e.newValue. From 05f0e4bfdaa2d4f06bb465d82dd7e6ca4c312c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:45:23 +0000 Subject: [PATCH 07/11] Track this tab's localStorage writes by storage name Vars of different state keys can share one storage name. Marking only the sent values of the var written missed a write through another var of the same name: the echo of a synced var sent before it was skipped, leaving storage on the older value while the tab showed the echo. Record the last write per storage name with a count of this tab's writes instead, which also drops the loop over pending values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex_base/.templates/web/utils/state.js | 24 ++++++--- .../test_hydration_storage.py | 51 +++++++++++++++---- 2 files changed, 58 insertions(+), 17 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 972fa40df4c..f6f402a2d7c 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 @@ -58,9 +58,13 @@ const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; // Browser storage values this tab sent to the backend, oldest first, by state -// key, each with the localStorage value this tab wrote after sending it. The -// backend echoes them back, so that get_delta overrides see them. +// key, each with the count of localStorage writes by this tab when it was +// sent. The backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; +// The localStorage writes by this tab: their count, and the last value and +// count by storage name, which vars of different state keys may share. +let localStorageWrites = 0; +const lastLocalStorageWrites = {}; // Bounds the values kept for a key whose replies never match, as when a // get_delta override changes them. It exceeds the values of one key in flight // at once: a synced var written at 60 Hz over a one second round trip. @@ -1066,7 +1070,7 @@ const recordSentStorageValues = (event) => { // ReflexEvent leaves out an empty payload. for (const [state_key, value] of Object.entries(event.payload?.vars ?? {})) { const sent = (sentStorageValues[state_key] ??= []); - sent.push({ value }); + sent.push({ value, writes: localStorageWrites }); if (sent.length > MAX_SENT_STORAGE_VALUES) { sent.shift(); } @@ -1130,11 +1134,17 @@ const applyClientStorageDelta = (client_storage, delta) => { // tab's newer one: writing the echo back would overwrite the newer one // and, for a synced var, make the other tabs answer with theirs, over // and over. - if (!echo || localStorage.getItem(name) === echo.written) { + const last = lastLocalStorageWrites[name]; + if ( + !echo || + (last?.writes > echo.writes && + localStorage.getItem(name) === last.value) + ) { localStorage.setItem(name, value); - for (const entry of sentStorageValues[state_key] ?? []) { - entry.written = value; - } + lastLocalStorageWrites[name] = { + value, + writes: ++localStorageWrites, + }; } } else if ( client_storage.session_storage && diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index b2e07f7d1c6..b2123bcc08a 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -137,6 +137,19 @@ def get_delta(self): subdelta["value" + FIELD_MARKER] = "replaced" return delta + class SharedNameState(rx.State): + synced: str = rx.LocalStorage("", name="hydrate-shared", sync=True) + plain: str = rx.LocalStorage("", name="hydrate-shared") + + @rx.event + def set_plain(self, value: str): + """Store a value through the var that shares the synced var's name. + + Args: + value: The value to store. + """ + self.plain = value + def synced(): """Display a browser storage var synced across tabs. @@ -149,6 +162,12 @@ def synced(): rx.button("New", on_click=SyncState.set_value("new"), id="set-new"), rx.text(ReplaceState.value, id="replace-value"), rx.button("Keep", on_click=ReplaceState.keep_sent, id="keep-sent"), + rx.text(SharedNameState.synced, id="shared-value"), + 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"), ) @@ -330,11 +349,11 @@ def test_assigned_storage_default_persists( # Stores a synced value as another tab would, optionally without its storage event. OTHER_TAB_STORES = """ -window.otherTabStores = (value, notify = true) => { - localStorage.setItem('hydrate-sync', value); +window.otherTabStores = (value, notify = true, key = 'hydrate-sync') => { + localStorage.setItem(key, value); if (notify) { window.dispatchEvent(new StorageEvent('storage', { - key: 'hydrate-sync', newValue: value, storageArea: localStorage, + key, newValue: value, storageArea: localStorage, })); } }; @@ -525,17 +544,29 @@ def test_synced_storage_echo_crossed_by_another_tab_is_not_written( assert page.evaluate("localStorage.getItem('hydrate-sync')") == "newer" +@pytest.mark.parametrize( + ("button", "key", "display"), + [ + ("set-old", "hydrate-sync", "sync-value"), + ("set-plain-old", "hydrate-shared", "shared-value"), + ], + ids=["same_var", "shared_name"], +) def test_synced_storage_echo_after_own_write_is_written( - hydration_storage_app: AppHarness, page: Page + hydration_storage_app: AppHarness, page: Page, button: str, key: str, display: str ): """An echo is written over a value the tab itself stored after sending it. The backend applies a tab's events in order, so the echo of a value sent - after a handler's event is newer than the value of the handler's reply. + after a handler's event is newer than the value of the handler's reply, + whether the handler stored the synced var or another var of the same name. Args: hydration_storage_app: The running app. page: A fresh browser page. + button: The button whose handler stores "old". + key: The storage name the handler writes. + display: The id of the element showing the synced var. """ assert hydration_storage_app.frontend_url is not None page.add_init_script(OTHER_TAB_STORES) @@ -544,15 +575,15 @@ def test_synced_storage_echo_after_own_write_is_written( expect(page.locator("#hydrated")).to_have_text("true") socket.hold() - page.locator("#set-old").click() - socket.wait_sent("set_value", '"old"') - page.evaluate("window.otherTabStores('sent')") + page.locator(f"#{button}").click() + socket.wait_sent('"old"') + page.evaluate(f"window.otherTabStores('sent', true, '{key}')") socket.wait_sent("update_vars_internal", '"sent"') socket.release() - expect(page.locator("#sync-value")).to_have_text("sent") + expect(page.locator(f"#{display}")).to_have_text("sent") page.wait_for_timeout(500) - assert page.evaluate("localStorage.getItem('hydrate-sync')") == "sent" + assert page.evaluate(f"localStorage.getItem('{key}')") == "sent" def test_replaced_storage_echo_does_not_hide_later_change( From 4cfafa35966a25d2f3a8dadde7d3ff1ab01bdd50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:05:03 +0000 Subject: [PATCH 08/11] Tighten the storage echo bookkeeping and its tests No behaviour change. Skip events without storage values before comparing event names, shorten the comments, and build the storage event's vars inline. The browser tests share one helper script and one function that opens the synced page, and pass values to evaluate as arguments instead of formatting them into the script. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex_base/.templates/web/utils/state.js | 72 +++++++-------- .../test_hydration_storage.py | 90 +++++++++---------- 2 files changed, 79 insertions(+), 83 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 f6f402a2d7c..589cbc832bf 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 @@ -53,22 +53,22 @@ let backend_state_mismatch = false; // Array holding pending events to be processed. const event_queue = []; -// The event that applies browser storage values changed in another tab. +// The event that sends browser storage values to the backend. const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; -// Browser storage values this tab sent to the backend, oldest first, by state -// key, each with the count of localStorage writes by this tab when it was -// sent. The backend echoes them back, so that get_delta overrides see them. +// Browser storage values sent to the backend, by state key, oldest first, each +// with the count of this tab's localStorage writes when it was sent. The +// backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; -// The localStorage writes by this tab: their count, and the last value and -// count by storage name, which vars of different state keys may share. +// Bounds the values kept for a key whose echoes an override changes, so they +// never match. One key has far fewer in flight: 60 writes a second over a one +// second round trip. +const MAX_SENT_STORAGE_VALUES = 256; +// This tab's localStorage writes so far, and its last write by storage name, +// which vars of different state keys may share. let localStorageWrites = 0; const lastLocalStorageWrites = {}; -// Bounds the values kept for a key whose replies never match, as when a -// get_delta override changes them. It exceeds the values of one key in flight -// at once: a synced var written at 60 Hz over a one second round trip. -const MAX_SENT_STORAGE_VALUES = 256; // Mirrors the data router's location so applyEvent can populate router_data // with the in-widget URL. In embed mode the host page's window.location is @@ -1061,14 +1061,16 @@ export const hydrateClientStorage = (client_storage) => { * @param event The event about to be sent. */ const recordSentStorageValues = (event) => { + // ReflexEvent leaves out an empty payload. + const vars = event.payload?.vars; if ( - event.name !== `${app.state_name}.hydrate_and_load` && - event.name !== `${app.state_name}.${UPDATE_VARS_INTERNAL}` + vars === undefined || + (event.name !== `${app.state_name}.hydrate_and_load` && + event.name !== `${app.state_name}.${UPDATE_VARS_INTERNAL}`) ) { return; } - // ReflexEvent leaves out an empty payload. - for (const [state_key, value] of Object.entries(event.payload?.vars ?? {})) { + for (const [state_key, value] of Object.entries(vars)) { const sent = (sentStorageValues[state_key] ??= []); sent.push({ value, writes: localStorageWrites }); if (sent.length > MAX_SENT_STORAGE_VALUES) { @@ -1078,15 +1080,15 @@ const recordSentStorageValues = (event) => { }; /** - * Take the sent value a backend value echoes, with the values sent before it. + * Take the sent value that a backend value echoes, and the older ones with it. * @param state_key The state key of the browser storage var. * @param value The value the backend sent for it. - * @returns The entry of the sent value, or undefined if the value is no echo. + * @returns The sent value's entry, or undefined if the value is no echo. */ -const takeSentStorageValue = (state_key, value) => { +const takeEcho = (state_key, value) => { const sent = sentStorageValues[state_key]; - const index = sent ? sent.findIndex((entry) => entry.value === value) : -1; - // The values sent before it were echoed already, or replaced on the way. + const index = sent?.findIndex((entry) => entry.value === value) ?? -1; + // Older values were echoed already, or changed by an override on the way. return index === -1 ? undefined : sent.splice(0, index + 1)[index]; }; @@ -1116,7 +1118,7 @@ const applyClientStorageDelta = (client_storage, delta) => { for (const key in delta[substate]) { const state_key = `${substate}.${key}`; const value = delta[substate][key]; - const echo = takeSentStorageValue(state_key, value); + const echo = takeEcho(state_key, value); if (client_storage.cookies && state_key in client_storage.cookies) { const cookie_options = { ...client_storage.cookies[state_key] }; const cookie_name = cookie_options.name || state_key; @@ -1127,14 +1129,12 @@ 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; - // The backend echoes the values a tab sends. Unless this tab wrote the - // value stored now after sending, it is the sent value or another - // tab's newer one: writing the echo back would overwrite the newer one - // and, for a synced var, make the other tabs answer with theirs, over - // and over. + const name = client_storage.local_storage[state_key].name || state_key; const last = lastLocalStorageWrites[name]; + // Write an echo only over this tab's own later write, which the backend + // applied first. Any other stored value is the sent one or another + // tab's newer one, and writing over that sets synced tabs answering + // each other over and over. if ( !echo || (last?.writes > echo.writes && @@ -1334,16 +1334,16 @@ export const useEventLoop = ( // e is StorageEvent const handleStorage = (e) => { + const state_key = storage_to_state_map[e.key]; // Session storage changes in same-origin frames raise storage events too. - if (e.storageArea === localStorage && storage_to_state_map[e.key]) { - const vars = {}; - // A tab gets the events of other tabs' writes after its own newer - // write too, so send the value stored now rather than e.newValue. - vars[storage_to_state_map[e.key]] = localStorage.getItem(e.key); - const event = ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { - vars: vars, - }); - addEvents([event], e); + if (state_key && e.storageArea === localStorage) { + // A tab can get another tab's event after its own newer write, so send + // the value stored now rather than e.newValue. + const vars = { [state_key]: localStorage.getItem(e.key) }; + addEvents( + [ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { vars })], + e, + ); } }; diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index b2123bcc08a..725a1c9486a 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -335,8 +335,10 @@ def test_assigned_storage_default_persists( assert page.evaluate("sessionStorage.getItem('assigned-session')") == "changed" -# Records every localStorage write of the synced var. -RECORD_SYNC_WRITES = """ +# Records the page's own writes of the synced var, and stores values as another +# tab would: its storage event carries `event` (the value stored by default, none +# if null). +SYNC_HELPERS = """ window.syncWrites = []; const setItem = Storage.prototype.setItem; Storage.prototype.setItem = function (key, value) { @@ -345,21 +347,35 @@ def test_assigned_storage_default_persists( } return setItem.call(this, key, value); }; -""" - -# Stores a synced value as another tab would, optionally without its storage event. -OTHER_TAB_STORES = """ -window.otherTabStores = (value, notify = true, key = 'hydrate-sync') => { - localStorage.setItem(key, value); - if (notify) { - window.dispatchEvent(new StorageEvent('storage', { - key, newValue: value, storageArea: localStorage, +window.otherTabStores = (value, { key = "hydrate-sync", event = value } = {}) => { + setItem.call(localStorage, key, value); + if (event !== null) { + window.dispatchEvent(new StorageEvent("storage", { + key, newValue: event, storageArea: localStorage, })); } }; """ +def open_synced(app: AppHarness, page: Page) -> str: + """Open the synced page with the helpers above and wait for hydration. + + Args: + app: The running app. + page: The page to open it in. + + Returns: + The URL of the synced page. + """ + assert app.frontend_url is not None + url = f"{app.frontend_url.rstrip('/')}/synced" + page.add_init_script(SYNC_HELPERS) + page.goto(url) + expect(page.locator("#hydrated")).to_have_text("true") + return url + + def wait_until(page: Page, condition: Callable[[], bool]): """Wait for a condition while Playwright keeps dispatching websocket messages. @@ -454,15 +470,12 @@ def test_synced_storage_echo_keeps_newer_value( hydration_storage_app: The running app. page: A fresh browser page. """ - assert hydration_storage_app.frontend_url is not None - url = f"{hydration_storage_app.frontend_url.rstrip('/')}/synced" - page.goto(url) - expect(page.locator("#hydrated")).to_have_text("true") + url = open_synced(hydration_storage_app, page) page.locator("#set-old").click() page.wait_for_function("localStorage.getItem('hydrate-sync') === 'old'") other = page.context.new_page() - other.add_init_script(RECORD_SYNC_WRITES) + other.add_init_script(SYNC_HELPERS) socket = EventSocket(other, hold=True) other.goto(url) # The boot event is sent once the page's effects, storage listener included, ran. @@ -493,18 +506,8 @@ def test_synced_storage_event_sends_stored_value( hydration_storage_app: The running app. page: A fresh browser page. """ - assert hydration_storage_app.frontend_url is not None - page.add_init_script(RECORD_SYNC_WRITES) - page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") - expect(page.locator("#hydrated")).to_have_text("true") - page.evaluate("""() => { - localStorage.setItem('hydrate-sync', 'stored'); - window.syncWrites.length = 0; - window.dispatchEvent(new StorageEvent('storage', { - key: 'hydrate-sync', oldValue: '', newValue: 'late', - storageArea: localStorage, - })); - }""") + open_synced(hydration_storage_app, page) + page.evaluate("window.otherTabStores('stored', { event: 'late' })") expect(page.locator("#sync-value")).to_have_text("stored") page.wait_for_timeout(500) assert "late" not in page.evaluate("window.syncWrites") @@ -524,17 +527,15 @@ def test_synced_storage_echo_crossed_by_another_tab_is_not_written( page: A fresh browser page. notified: Whether the tab got the newer value's storage event before the echo. """ - assert hydration_storage_app.frontend_url is not None - page.add_init_script(RECORD_SYNC_WRITES) - page.add_init_script(OTHER_TAB_STORES) - page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") - expect(page.locator("#hydrated")).to_have_text("true") + open_synced(hydration_storage_app, page) # Another tab stores "sent", then "newer" before the echo of "sent" arrives. - page.evaluate(f"""() => {{ - window.otherTabStores('sent'); - window.otherTabStores('newer', {str(notified).lower()}); - window.syncWrites.length = 0; - }}""") + page.evaluate( + """notified => { + window.otherTabStores('sent'); + window.otherTabStores('newer', { event: notified ? 'newer' : null }); + }""", + notified, + ) if not notified: expect(page.locator("#sync-value")).to_have_text("sent") page.evaluate("window.otherTabStores('newer')") @@ -568,22 +569,19 @@ def test_synced_storage_echo_after_own_write_is_written( key: The storage name the handler writes. display: The id of the element showing the synced var. """ - assert hydration_storage_app.frontend_url is not None - page.add_init_script(OTHER_TAB_STORES) socket = EventSocket(page) - page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") - expect(page.locator("#hydrated")).to_have_text("true") + open_synced(hydration_storage_app, page) socket.hold() page.locator(f"#{button}").click() socket.wait_sent('"old"') - page.evaluate(f"window.otherTabStores('sent', true, '{key}')") + page.evaluate("key => window.otherTabStores('sent', { key })", key) socket.wait_sent("update_vars_internal", '"sent"') socket.release() expect(page.locator(f"#{display}")).to_have_text("sent") page.wait_for_timeout(500) - assert page.evaluate(f"localStorage.getItem('{key}')") == "sent" + assert page.evaluate("key => localStorage.getItem(key)", key) == "sent" def test_replaced_storage_echo_does_not_hide_later_change( @@ -595,9 +593,7 @@ def test_replaced_storage_echo_does_not_hide_later_change( hydration_storage_app: The running app. page: A fresh browser page. """ - assert hydration_storage_app.frontend_url is not None - page.goto(f"{hydration_storage_app.frontend_url.rstrip('/')}/synced") - expect(page.locator("#hydrated")).to_have_text("true") + open_synced(hydration_storage_app, page) page.evaluate("localStorage.setItem('hydrate-replace', 'sent')") page.reload() expect(page.locator("#hydrated")).to_have_text("true") From 33bf76643c4a544a4173871a2291bdce9f690a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:23:41 +0000 Subject: [PATCH 09/11] Read a synced localStorage value when its event is sent A storage event queued the update_vars_internal event with the value read when the event fired, but the write count that decides whether its echo is written was recorded when the event was sent. The two differ when the event waits in the queue for a reconnect: storage can change again before it is sent, so the tab sent a stale value, its backend ended on that value, and the echo rule could skip a write it should have made. The storage event now queues an internal _sync_local_storage event that names the var, and applyEvent builds the update_vars_internal event from localStorage at the moment it is sent, so the value and the write count come from the same moment. isStateful counts that event as one that needs the backend, so it waits for the reconnect instead of being emitted on a disconnected socket. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01B1Z3tBfDNbBQUURGTiehyf --- .../reflex_base/.templates/web/utils/state.js | 34 +++++++++++++++---- .../test_hydration_storage.py | 31 +++++++++++++++++ 2 files changed, 59 insertions(+), 6 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 589cbc832bf..cb2910c47e4 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 @@ -56,6 +56,9 @@ const event_queue = []; // The event that sends browser storage values to the backend. const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; +// The frontend event that sends one synced localStorage var. It is built into +// an UPDATE_VARS_INTERNAL event when it is sent: see syncLocalStorageEvent. +const SYNC_LOCAL_STORAGE = "_sync_local_storage"; // Browser storage values sent to the backend, by state key, oldest first, each // with the count of this tab's localStorage writes when it was sent. The @@ -258,7 +261,8 @@ export const isStateful = () => { return event_queue.some( (event) => typeof event?.name === "string" && - event.name.startsWith("reflex___state"), + (event.name.startsWith("reflex___state") || + event.name === SYNC_LOCAL_STORAGE), ); }; @@ -345,6 +349,21 @@ function urlFrom(string) { return undefined; } +/** + * Build the event that sends a synced localStorage var to the backend. + * + * A storage event queues only which var to send. The value is read here, when + * the event is sent, so that it and the write count recorded with it come from + * the same moment: the event may have waited for a reconnect, and storage may + * have changed meanwhile. + * @param payload The storage name and state key of the var. + * @returns The update_vars_internal event. + */ +const syncLocalStorageEvent = ({ key, state_key }) => + ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { + vars: { [state_key]: localStorage.getItem(key) }, + }); + /** * Handle frontend event or send the event to the backend via Websocket. * @param event The event to send. @@ -530,7 +549,12 @@ export const applyEvent = async (event, socket, navigate, params) => { // Send the event to the server. if (socket) { - const routed_event = withRouterData(event, params); + const routed_event = withRouterData( + event.name == SYNC_LOCAL_STORAGE + ? syncLocalStorageEvent(event.payload) + : event, + params, + ); recordSentStorageValues(routed_event); // Instrumentation hook (installed by reflex-otel): may add a traceparent. window.__reflex_otel?.onEventSend?.(routed_event); @@ -1337,11 +1361,9 @@ export const useEventLoop = ( const state_key = storage_to_state_map[e.key]; // Session storage changes in same-origin frames raise storage events too. if (state_key && e.storageArea === localStorage) { - // A tab can get another tab's event after its own newer write, so send - // the value stored now rather than e.newValue. - const vars = { [state_key]: localStorage.getItem(e.key) }; + // The value is read when the event is sent: see syncLocalStorageEvent. addEvents( - [ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, { vars })], + [ReflexEvent(SYNC_LOCAL_STORAGE, { key: e.key, state_key })], e, ); } diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 725a1c9486a..3949fa9a8df 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -605,3 +605,34 @@ def test_replaced_storage_echo_does_not_hide_later_change( page.wait_for_function("localStorage.getItem('hydrate-replace') === 'sent'") page.wait_for_timeout(500) expect(page.locator("#replace-value")).to_have_text("sent") + + +def test_synced_storage_event_while_disconnected_sends_current_value( + hydration_storage_app: AppHarness, page: Page +): + """A storage event received while disconnected syncs the value stored at send time. + + The event waits for the reconnect, and storage can change again before it + is sent. Sending the value from when the event fired would leave this tab's + backend behind storage and the other tabs. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + """ + socket = EventSocket(page) + open_synced(hydration_storage_app, page) + # The tab disconnects as on navigation, another tab stores a value, and then + # another before the reconnect sends the queued event. + page.evaluate( + """() => { + window.dispatchEvent(new Event('pagehide')); + window.otherTabStores('stale'); + window.otherTabStores('fresh', { event: null }); + }""" + ) + socket.wait_sent("update_vars_internal") + page.wait_for_timeout(500) + expect(page.locator("#sync-value")).to_have_text("fresh") + assert not any("update_vars_internal" in m and '"stale"' in m for m in socket.sent) + assert page.evaluate("localStorage.getItem('hydrate-sync')") == "fresh" From 2063422c836a9a9692f9ed1fe433619e90bb24a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:19:29 +0000 Subject: [PATCH 10/11] Skip echoes only for synced localStorage vars A value sent at boot whose echo a get_delta override replaced stays recorded, so a later handler that sets the same value looks like its echo. For a var that is not synced, the echo rule then skipped the handler's value whenever another tab had written the key since this tab's own last write: storage kept the other tab's value while the backend held the handler's, and nothing brought them back together. Skipping an echo is safe only because the tab sends another tab's newer value to its backend, which happens for synced vars alone. Only synced vars now record the values they send, and every other value is written as on main. A synced var's stale entry is cleared by the sync of the other tab's value; until then the backend applies that sync after the handler, so storage and backend still agree. test_replaced_storage_echo_does_not_hide_later_change now covers a var that is not synced and another tab writing before the handler; the plain var with another tab's write fails on 33bf76643 in dev and prod. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --- .../reflex_base/.templates/web/utils/state.js | 23 ++++++---- .../test_hydration_storage.py | 44 ++++++++++++++----- 2 files changed, 47 insertions(+), 20 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 cb2910c47e4..35c89acf1e8 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 @@ -60,8 +60,8 @@ const UPDATE_VARS_INTERNAL = // an UPDATE_VARS_INTERNAL event when it is sent: see syncLocalStorageEvent. const SYNC_LOCAL_STORAGE = "_sync_local_storage"; -// Browser storage values sent to the backend, by state key, oldest first, each -// with the count of this tab's localStorage writes when it was sent. The +// Synced localStorage values sent to the backend, by state key, oldest first, +// each with the count of this tab's localStorage writes when it was sent. The // backend echoes them back, so that get_delta overrides see them. const sentStorageValues = {}; // Bounds the values kept for a key whose echoes an override changes, so they @@ -1081,7 +1081,10 @@ export const hydrateClientStorage = (client_storage) => { }; /** - * Remember the browser storage values an event sends to the backend. + * Remember the synced localStorage values an event sends to the backend. + * + * Only a synced var's echo may go unwritten, because only for a synced var + * does the tab send the newer value another tab stored to its backend. * @param event The event about to be sent. */ const recordSentStorageValues = (event) => { @@ -1094,7 +1097,11 @@ const recordSentStorageValues = (event) => { ) { return; } + const local_storage = app.clientStorage.local_storage; for (const [state_key, value] of Object.entries(vars)) { + if (!local_storage?.[state_key]?.sync) { + continue; + } const sent = (sentStorageValues[state_key] ??= []); sent.push({ value, writes: localStorageWrites }); if (sent.length > MAX_SENT_STORAGE_VALUES) { @@ -1142,7 +1149,6 @@ const applyClientStorageDelta = (client_storage, delta) => { for (const key in delta[substate]) { const state_key = `${substate}.${key}`; const value = delta[substate][key]; - const echo = takeEcho(state_key, value); if (client_storage.cookies && state_key in client_storage.cookies) { const cookie_options = { ...client_storage.cookies[state_key] }; const cookie_name = cookie_options.name || state_key; @@ -1154,11 +1160,12 @@ const applyClientStorageDelta = (client_storage, delta) => { typeof window !== "undefined" ) { 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 - // applied first. Any other stored value is the sent one or another - // tab's newer one, and writing over that sets synced tabs answering - // each other over and over. + // Write a synced var's echo only over this tab's own later write, which + // the backend applied first. Any other stored value is the sent one or + // another tab's newer one, which this tab syncs to its backend; writing + // over it sets synced tabs answering each other over and over. if ( !echo || (last?.writes > echo.writes && diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 3949fa9a8df..684b47e050f 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -116,25 +116,27 @@ def set_value(self, value: str): class ReplaceState(rx.State): value: str = rx.LocalStorage("", name="hydrate-replace", sync=True) + plain: str = rx.LocalStorage("", name="hydrate-replace-plain") _keep: bool = False @rx.event def keep_sent(self): """Store the value the override replaced at boot, now unreplaced.""" self._keep = True - self.value = "sent" + self.value = self.plain = "sent" @rx.state._override_base_method def get_delta(self): - """Replace the stored value until a handler stores it on purpose. + """Replace the stored values until a handler stores them on purpose. Returns: - The delta, with the value replaced. + The delta, with the values replaced. """ delta = super().get_delta() subdelta = delta.get(self.get_full_name(), {}) - if not self._keep and subdelta.get("value" + FIELD_MARKER) == "sent": - subdelta["value" + FIELD_MARKER] = "replaced" + for var in ("value", "plain"): + if not self._keep and subdelta.get(var + FIELD_MARKER) == "sent": + subdelta[var + FIELD_MARKER] = "replaced" return delta class SharedNameState(rx.State): @@ -161,6 +163,7 @@ def synced(): rx.button("Old", on_click=SyncState.set_value("old"), id="set-old"), rx.button("New", on_click=SyncState.set_value("new"), id="set-new"), rx.text(ReplaceState.value, id="replace-value"), + 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.button( @@ -584,27 +587,44 @@ def test_synced_storage_echo_after_own_write_is_written( assert page.evaluate("key => localStorage.getItem(key)", key) == "sent" +@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( - hydration_storage_app: AppHarness, page: Page + hydration_storage_app: AppHarness, page: Page, synced: bool, other_tab_stores: bool ): """A value sent at boot whose echo was replaced is written when a handler sets it. + That holds after another tab stored a value meanwhile too, which a synced var + sends to the backend and a var that is not synced never does. + Args: hydration_storage_app: The running app. page: A fresh browser page. + synced: Whether the var is synced across tabs. + other_tab_stores: Whether another tab stores a value before the handler runs. """ + key, display = ( + ("hydrate-replace", "#replace-value") + if synced + else ("hydrate-replace-plain", "#replace-plain") + ) open_synced(hydration_storage_app, page) - page.evaluate("localStorage.setItem('hydrate-replace', 'sent')") + page.evaluate("key => localStorage.setItem(key, 'sent')", key) page.reload() expect(page.locator("#hydrated")).to_have_text("true") - expect(page.locator("#replace-value")).to_have_text("replaced") - page.wait_for_function("localStorage.getItem('hydrate-replace') === 'replaced'") + expect(page.locator(display)).to_have_text("replaced") + page.wait_for_function(f"localStorage.getItem('{key}') === 'replaced'") + + if other_tab_stores: + page.evaluate("key => window.otherTabStores('newer', { key })", key) + expect(page.locator(display)).to_have_text("newer" if synced else "replaced") page.locator("#keep-sent").click() - expect(page.locator("#replace-value")).to_have_text("sent") - page.wait_for_function("localStorage.getItem('hydrate-replace') === 'sent'") + expect(page.locator(display)).to_have_text("sent") + page.wait_for_function(f"localStorage.getItem('{key}') === 'sent'") page.wait_for_timeout(500) - expect(page.locator("#replace-value")).to_have_text("sent") + expect(page.locator(display)).to_have_text("sent") + assert page.evaluate("key => localStorage.getItem(key)", key) == "sent" def test_synced_storage_event_while_disconnected_sends_current_value( From 3df9341a5748657d8a9d737a0a5aaec53e5a0bed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:26:32 +0000 Subject: [PATCH 11/11] Tersify the storage echo comments and test docstrings Comments and docstrings now say what the code does. The reasoning, the measurements and the rejected approaches stay in the commit messages and the PR body. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01B1Z3tBfDNbBQUURGTiehyf --- .../reflex_base/.templates/web/utils/state.js | 37 ++++++++----------- .../test_hydration_storage.py | 27 ++++---------- 2 files changed, 23 insertions(+), 41 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 35c89acf1e8..c0dc568ad56 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 @@ -56,20 +56,17 @@ const event_queue = []; // The event that sends browser storage values to the backend. const UPDATE_VARS_INTERNAL = "reflex___state____update_vars_internal_state.update_vars_internal"; -// The frontend event that sends one synced localStorage var. It is built into -// an UPDATE_VARS_INTERNAL event when it is sent: see syncLocalStorageEvent. +// A frontend event naming one synced localStorage var, turned into an +// UPDATE_VARS_INTERNAL event when it is sent: see syncLocalStorageEvent. const SYNC_LOCAL_STORAGE = "_sync_local_storage"; -// Synced localStorage values sent to the backend, by state key, oldest first, -// each with the count of this tab's localStorage writes when it was sent. The -// backend echoes them back, so that get_delta overrides see them. +// Synced localStorage values sent to the backend and not yet echoed back, by +// state key, oldest first, with this tab's localStorage write count at the send. const sentStorageValues = {}; -// Bounds the values kept for a key whose echoes an override changes, so they -// never match. One key has far fewer in flight: 60 writes a second over a one -// second round trip. +// Bounds a key whose echoes a get_delta override changes, so they never match. const MAX_SENT_STORAGE_VALUES = 256; -// This tab's localStorage writes so far, and its last write by storage name, -// which vars of different state keys may share. +// This tab's localStorage write count, and its last write by storage name +// (vars of different state keys may share one). let localStorageWrites = 0; const lastLocalStorageWrites = {}; @@ -350,12 +347,10 @@ function urlFrom(string) { } /** - * Build the event that sends a synced localStorage var to the backend. + * Build the update_vars_internal event for a synced localStorage var. * - * A storage event queues only which var to send. The value is read here, when - * the event is sent, so that it and the write count recorded with it come from - * the same moment: the event may have waited for a reconnect, and storage may - * have changed meanwhile. + * The value is read when the event is sent, so it and the write count recorded + * with it come from the same moment. * @param payload The storage name and state key of the var. * @returns The update_vars_internal event. */ @@ -1081,10 +1076,9 @@ export const hydrateClientStorage = (client_storage) => { }; /** - * Remember the synced localStorage values an event sends to the backend. + * Remember the synced localStorage values an event sends, to recognise their echoes. * - * Only a synced var's echo may go unwritten, because only for a synced var - * does the tab send the newer value another tab stored to its backend. + * Only synced vars: the tab sends another tab's newer value only for those. * @param event The event about to be sent. */ const recordSentStorageValues = (event) => { @@ -1162,10 +1156,9 @@ const applyClientStorageDelta = (client_storage, delta) => { const name = client_storage.local_storage[state_key].name || state_key; const echo = takeEcho(state_key, value); const last = lastLocalStorageWrites[name]; - // Write a synced var's echo only over this tab's own later write, which - // the backend applied first. Any other stored value is the sent one or - // another tab's newer one, which this tab syncs to its backend; writing - // over it sets synced tabs answering each other over and over. + // Write an echo only over this tab's own later write, which the backend + // applied before the echoed event. Otherwise storage holds the sent + // value or another tab's newer one. if ( !echo || (last?.writes > echo.writes && diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 684b47e050f..7a8af6f48d5 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -464,10 +464,8 @@ def test_synced_storage_echo_keeps_newer_value( ): """A booting tab does not write back a synced value another tab changed meanwhile. - The second tab boots with the stored value while its websocket replies are - held back; the first tab then stores a newer value. Once the replies arrive, - the second tab must not write the value it booted with over the newer one, - which would also echo it back to the first tab through a storage event. + The second tab boots with the stored value while its replies are held back, + and the first tab stores a newer value before they arrive. Args: hydration_storage_app: The running app. @@ -500,10 +498,7 @@ def test_synced_storage_echo_keeps_newer_value( def test_synced_storage_event_sends_stored_value( hydration_storage_app: AppHarness, page: Page ): - """A late storage event syncs the value stored now, not the value in the event. - - A tab can get the event of another tab's write after a later write, so the - event's ``newValue`` may already be stale. + """A late storage event syncs the value stored now, not the event's ``newValue``. Args: hydration_storage_app: The running app. @@ -561,9 +556,8 @@ def test_synced_storage_echo_after_own_write_is_written( ): """An echo is written over a value the tab itself stored after sending it. - The backend applies a tab's events in order, so the echo of a value sent - after a handler's event is newer than the value of the handler's reply, - whether the handler stored the synced var or another var of the same name. + The handler's reply is older than the echo, whether the handler stored the + synced var or another var of the same name. Args: hydration_storage_app: The running app. @@ -594,8 +588,7 @@ def test_replaced_storage_echo_does_not_hide_later_change( ): """A value sent at boot whose echo was replaced is written when a handler sets it. - That holds after another tab stored a value meanwhile too, which a synced var - sends to the backend and a var that is not synced never does. + Also after another tab stored a value meanwhile, synced or not. Args: hydration_storage_app: The running app. @@ -632,18 +625,14 @@ def test_synced_storage_event_while_disconnected_sends_current_value( ): """A storage event received while disconnected syncs the value stored at send time. - The event waits for the reconnect, and storage can change again before it - is sent. Sending the value from when the event fired would leave this tab's - backend behind storage and the other tabs. - Args: hydration_storage_app: The running app. page: A fresh browser page. """ socket = EventSocket(page) open_synced(hydration_storage_app, page) - # The tab disconnects as on navigation, another tab stores a value, and then - # another before the reconnect sends the queued event. + # Disconnect as on navigation, then another tab stores twice before the + # reconnect sends the queued event. page.evaluate( """() => { window.dispatchEvent(new Event('pagehide'));