Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Synced `LocalStorage` vars that share one storage `name` now update in the tab that writes, not only in other tabs.
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,18 @@ export const connect = async (
for (const substate in delta) {
dispatch[substate](delta[substate]);
}
applyClientStorageDelta(client_storage, delta);
const pending_syncs = applyClientStorageDelta(client_storage, delta);
if (pending_syncs.length > 0) {
queueEvents(
pending_syncs.map(({ key, state_keys }) =>
ReflexEvent(SYNC_LOCAL_STORAGE, { key, state_keys }),
),
socket,
false,
navigate,
params,
).catch(reportStateUpdateError);
}
}
if (update.events && update.events.length > 0) {
queueEvents(update.events, socket, false, navigate, params).catch(
Expand Down Expand Up @@ -1363,8 +1374,13 @@ const takeEcho = (state_key, value) => {
* Update client storage values based on backend state delta.
* @param client_storage The client storage object from context.js
* @param delta The state update from the backend
* @returns Storage syncs to send for this tab's sibling vars sharing a
* written storage name, as [{key, state_keys}]. The storage event reaches
* other tabs only, so same-tab siblings are synced from the write here.
*/
const applyClientStorageDelta = (client_storage, delta) => {
// Syncs to send, keyed by storage name so one name syncs once per delta.
const pending_syncs = new Map();
// find the main state and check for is_hydrated
const unqualified_states = Object.keys(delta).filter(
(key) => key.split(".").length === 1,
Expand All @@ -1377,7 +1393,7 @@ const applyClientStorageDelta = (client_storage, delta) => {
) {
// skip if the state is not hydrated yet, since all client storage
// values are sent in the hydrate event
return;
return [];
}
}
// Save known client storage values to cookies and localStorage.
Expand All @@ -1395,7 +1411,8 @@ const applyClientStorageDelta = (client_storage, delta) => {
state_key in client_storage.local_storage &&
typeof window !== "undefined"
) {
const name = client_storage.local_storage[state_key].name || state_key;
const options = client_storage.local_storage[state_key];
const name = options.name || state_key;
const echo = takeEcho(state_key, value);
const last = lastLocalStorageWrites[name];
// Write an echo only over this tab's own later write, which the backend
Expand All @@ -1406,11 +1423,43 @@ const applyClientStorageDelta = (client_storage, delta) => {
(last?.writes > echo.writes &&
localStorage.getItem(name) === last.value)
) {
const previous = localStorage.getItem(name);
localStorage.setItem(name, value);
lastLocalStorageWrites[name] = {
value,
writes: ++localStorageWrites,
};
// The storage event fires in other tabs only, so queue the sync for
// this tab's synced vars sharing the name here, whether or not the
// written var itself syncs (storage writes raise the event
// cross-tab either way). Like handleStorage, the sync covers every
// synced var with the name: when one delta writes several of them,
// the last write wins in storage, and the sync reads that value.
// Skip an unchanged value: its vars are already in sync, and
// re-sending would ping-pong when an echo misses. Deltas are
// per-session, and a sent update echoes back and takeEcho keeps it
// from writing again.
if (previous !== String(value) && !pending_syncs.has(name)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const state_keys = Object.keys(client_storage.local_storage).filter(
(other_key) =>
client_storage.local_storage[other_key].sync &&
(client_storage.local_storage[other_key].name || other_key) ===
name,
);
// Sync only when another var shares the name. Skip when a synced
// var with the name has an unechoed sent value: this delta replies
// to an older event than that value. The echo of the newer value
// carries every applied key for the name, so storage and the
// sibling vars converge without a sync from this stale delta.
if (
state_keys.some((other_key) => other_key !== state_key) &&
!state_keys.some(
(other_key) => sentStorageValues[other_key]?.length > 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This guard can permanently suppress sibling sync after a sent value is reconciled to a different backend value. takeEcho removes entries only on exact matches, so an outstanding entry left by a get_delta override makes every later delta skip pending_syncs; track pending sends by the applied backend update and clear or reconcile nonmatching echoes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/.templates/web/utils/state.js, line 1457:

<comment>This guard can permanently suppress sibling sync after a sent value is reconciled to a different backend value. `takeEcho` removes entries only on exact matches, so an outstanding entry left by a `get_delta` override makes every later delta skip `pending_syncs`; track pending sends by the applied backend update and clear or reconcile nonmatching echoes.</comment>

<file context>
@@ -1446,8 +1446,17 @@ const applyClientStorageDelta = (client_storage, delta) => {
+            if (
+              state_keys.some((other_key) => other_key !== state_key) &&
+              !state_keys.some(
+                (other_key) => sentStorageValues[other_key]?.length > 0,
+              )
+            ) {
</file context>

)
Comment on lines +1456 to +1458

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared vars stop updating

When a get_delta override replaces or drops a sent value, takeEcho leaves its entry in sentStorageValues: it only removes entries that exactly match the returned value. The new guard treats that old entry as a pending reply and skips every later sibling sync for the storage name. Later writes therefore leave the writing tab's vars different.

Track pending replies separately from the value history, and add a shared-name test with a replaced echo.

) {
pending_syncs.set(name, state_keys);
}
Comment on lines +1442 to +1461

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sync can send a value that is older than one this tab already sent for the sibling. The delta is the reply to an earlier event. If the tab sent update_vars_internal for a sibling after that event (from another tab's storage event), the backend applies this sync after that newer value, and the sync's echo overwrites it.

Sequence with SharedNameState from test_hydration_storage.py (synced and plain share one name):

  1. Click set-plain-old. The reply is slow.
  2. Another tab stores sent. The storage event sends synced: "sent".
  3. The plain: "old" delta arrives. It writes storage, and this block sends synced: "old".
  4. The echo of sent writes storage back to sent.
  5. The echo of old passes the last.writes > echo.writes check and writes old.

Both tabs end on old. On main they end on sent. This is the test_synced_storage_echo_after_own_write_is_written[shared_name] failure in CI. It also reproduces in a browser with a 1.5 s asyncio.sleep before self.plain = value.

Please skip the sibling sync when a synced var with this name has an unechoed entry in sentStorageValues. Its echo is newer than this delta, and when that echo changes storage, this block queues the sibling sync from it with the correct value.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the guard and the same-tab Playwright regression test. One edge case: if get_delta rewrites or drops a sent value, or its event errors, an unmatched sent entry can suppress later sibling syncs for that name until a matching echo clears it or reconnect/hydrate heals it. While suppressed, the same-tab sibling can remain stale, as on main. I've kept this scoped to your prescribed ordering fix. Is that limitation acceptable here, or should I address it in this PR?

}
}
} else if (
client_storage.session_storage &&
Expand All @@ -1422,6 +1471,7 @@ const applyClientStorageDelta = (client_storage, delta) => {
}
}
}
return [...pending_syncs].map(([key, state_keys]) => ({ key, state_keys }));
};

/**
Expand Down
38 changes: 38 additions & 0 deletions tests/integration/tests_playwright/test_hydration_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def get_delta(self):

class SharedNameState(rx.State):
synced: str = rx.LocalStorage("", name="hydrate-shared", sync=True)
synced2: str = rx.LocalStorage("", name="hydrate-shared", sync=True)
plain: str = rx.LocalStorage("", name="hydrate-shared")

@rx.event
Expand All @@ -160,6 +161,15 @@ def set_plain(self, value: str):
"""
self.plain = value

@rx.event
def set_synced(self, value: str):
"""Store a value through the synced var.

Args:
value: The value to store.
"""
self.synced = value

def synced():
"""Display a browser storage var synced across tabs.

Expand All @@ -174,11 +184,17 @@ def synced():
rx.text(ReplaceState.plain, id="replace-plain"),
rx.button("Keep", on_click=ReplaceState.keep_sent, id="keep-sent"),
rx.text(SharedNameState.synced, id="shared-value"),
rx.text(SharedNameState.synced2, id="shared-value-2"),
rx.button(
"Plain old",
on_click=SharedNameState.set_plain("old"),
id="set-plain-old",
),
rx.button(
"Synced new",
on_click=SharedNameState.set_synced("new"),
id="set-synced-new",
),
rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"),
)

Expand Down Expand Up @@ -589,6 +605,28 @@ def test_synced_storage_echo_after_own_write_is_written(
assert page.evaluate("key => localStorage.getItem(key)", key) == "sent"


def test_synced_storage_shared_name_syncs_siblings_in_writing_tab(
hydration_storage_app: AppHarness, page: Page
):
"""A write to one synced var sharing a storage name updates the others in the writing tab.

The storage event only fires in other tabs, so the writing tab syncs its
sibling vars sharing the name itself (issue #7567).

Args:
hydration_storage_app: The running app.
page: A fresh browser page.
"""
open_synced(hydration_storage_app, page)
page.locator("#set-synced-new").click()
expect(page.locator("#shared-value")).to_have_text("new")
expect(page.locator("#shared-value-2")).to_have_text("new")
page.wait_for_timeout(500)
expect(page.locator("#shared-value")).to_have_text("new")
expect(page.locator("#shared-value-2")).to_have_text("new")
assert page.evaluate("localStorage.getItem('hydrate-shared')") == "new"


@pytest.mark.parametrize("other_tab_stores", [False, True], ids=["alone", "other_tab"])
@pytest.mark.parametrize("synced", [True, False], ids=["synced", "plain"])
def test_replaced_storage_echo_does_not_hide_later_change(
Expand Down
Loading
Loading