Skip to content

fix: sync shared-name storage vars in the writing tab (#7567) - #7637

Open
anishgurjar wants to merge 4 commits into
reflex-dev:mainfrom
anishgurjar:fix-7567-same-tab-storage-sync
Open

anishgurjar wants to merge 4 commits into
reflex-dev:mainfrom
anishgurjar:fix-7567-same-tab-storage-sync

Conversation

@anishgurjar

@anishgurjar anishgurjar commented Oct 22, 2026 •

Copy link
Copy Markdown

Fixes #7567

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

What

Several rx.LocalStorage(..., sync=True) vars can share one storage name (the common case is a named synced field on an rx.ComponentState used more than once). When the backend applies a write to one of them, applyClientStorageDelta updates localStorage, which raises a storage event in the other tabs only. The writing tab never hears it, so its sibling vars sharing the name stay stale until a reload.

Following the fix sketched in the issue: when a synced var's value is written to its storage name and the write actually changes storage, applyClientStorageDelta now also queues the same SYNC_LOCAL_STORAGE event for the other synced vars with that name in this tab, once per storage name per delta. The written var's own sync flag does not matter, matching the cross-tab path: storage writes raise the storage event either way. Deltas are per-session, so other tabs are unaffected and still sync through the real event. The sibling update records its sent values, so the echoing delta is recognized by takeEcho and neither writes localStorage again nor queues another sync; a genuinely newer backend value overwrites, re-syncs once, and its echo terminates the cycle.

Tests

Five new cases in tests/units/compiler/state_js.test.mjs (run through test_state_js_template.py): sibling sync on a synced write with three vars sharing a name; a write from an unsynced var still syncing its synced siblings; one sync per storage name when one delta writes two same-name vars; no writes or syncs from a not-yet-hydrated delta; and a newer backend value overwriting, re-syncing once, then terminating on its echo.

All five pass with the change; four of them fail against the template on main (the hydrate case guards existing behavior). The full file passes 37/37 and both files are prettier-clean. The test harness's localStorage mock gained setItem and its socket mock records emit so the delta path can be driven directly. I kept this to unit coverage since the existing state_js.test.mjs harness drives the real template with controlled browser APIs; happy to add a Playwright test if you'd like one.

@anishgurjar
anishgurjar requested a review from a team as a code owner October 22, 2026 18:32

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/units/compiler/state_js.test.mjs">

<violation number="1" location="tests/units/compiler/state_js.test.mjs:758">
P2: This echo check cannot detect a repeated `localStorage.setItem`: the map value remains `"from-cs0"` after another identical write. Record setter calls (or expose a write log) and assert the echoed update leaves the write count unchanged.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic


// The storage event only fires in other tabs, so the writing tab syncs its
// own sibling vars sharing the name.
assert.equal(app.localStorage.items.get("shared"), "from-cs0");

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 echo check cannot detect a repeated localStorage.setItem: the map value remains "from-cs0" after another identical write. Record setter calls (or expose a write log) and assert the echoed update leaves the write count unchanged.

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 tests/units/compiler/state_js.test.mjs, line 758:

<comment>This echo check cannot detect a repeated `localStorage.setItem`: the map value remains `"from-cs0"` after another identical write. Record setter calls (or expose a write log) and assert the echoed update leaves the write count unchanged.</comment>

<file context>
@@ -724,3 +732,64 @@ test("storage names that are Object.prototype properties sync like any other", a
+
+  // The storage event only fires in other tabs, so the writing tab syncs its
+  // own sibling vars sharing the name.
+  assert.equal(app.localStorage.items.get("shared"), "from-cs0");
+  assert.equal(syncs().length, 1, JSON.stringify(app.emits));
+  assert.equal(
</file context>

@anishgurjar
anishgurjar force-pushed the fix-7567-same-tab-storage-sync branch from d0cba9f to d931987 Compare October 22, 2026 18:36
@anishgurjar
anishgurjar force-pushed the fix-7567-same-tab-storage-sync branch from d931987 to 344ae42 Compare October 22, 2026 18:37
@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects browser storage syncing in apps that share names across synced variables.

Fix the guard's handling of changed echoes before merging.

Findings

  1. P1 Shared vars stop updating ▶

Summary

Updates same-name synced LocalStorage vars in the writing tab.

  • Shared storage names sync their sibling variables in the writing tab.

Reviews (4) · Last reviewed commit: "Guard same-tab sibling sync against unec..." · Reviewed by Greptile

Comment thread packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

View guided diff | Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
One delta writing two same-name vars with different values would queue a
sync that excluded the earlier writer, leaving it on the old value while
storage held the last write. Build the sync set from every synced var
with the name, like handleStorage, so the last write converges all of
them.
@anishgurjar

Copy link
Copy Markdown
Author

Hi @masenf, this is ready for a look when you have a moment. The workflows are waiting on first-time-contributor approval; the new state.js tests pass locally (37/37, and the new ones fail against the template on main).

@FarhanAliRaza FarhanAliRaza added the on deck PRs lined up to review / merge next label Oct 22, 2026
@codspeed

codspeed Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 18 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 148 untouched benchmarks


Comparing anishgurjar:fix-7567-same-tab-storage-sync (41d7be6) with main (15b086f)

Open in CodSpeed

@masenf

masenf commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

The required app-harness workflow now fails test_synced_storage_echo_after_own_write_is_written[..., shared_name] in both dev/prod modes across all ten memory/Redis Python matrix legs. The assertion expects sent but receives old (representative job). This is reproducible, not a transient CI failure.

I reduced it against the actual state.js module with Node 24: the existing 37 tests pass, while this ordering case fails:

  1. Another tab's sent value is sent through update_vars_internal, while an older handler reply is delayed.
  2. That older reply sets the unsynced sibling to old; the new same-tab path emits an additional synced-sibling update carrying old.
  3. The original sent echo arrives and restores storage to sent.
  4. The additional old echo arrives last and overwrites it again.

The same-tab sync generated from an older response is being treated as a fresh write. This needs an ordering fix that preserves same-tab propagation and the existing cross-tab echo protection; simply suppressing every pending echo can also suppress legitimate updates whose backend delta was transformed. I have left the synchronization design to the author/reviewers and have not rerun the deterministic failure.

Comment on lines +1442 to +1452
if (previous !== String(value) && !pending_syncs.has(name)) {
const state_keys = Object.keys(client_storage.local_storage).filter(
(other_key) =>
client_storage.local_storage[other_key].sync &&
(client_storage.local_storage[other_key].name || other_key) ===
name,
);
// Sync only when another var shares the name.
if (state_keys.some((other_key) => other_key !== state_key)) {
pending_syncs.set(name, state_keys);
}

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?

);
});

test("a synced storage write syncs the writing tab's sibling vars", async () => {

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.

Please add a browser test for the fixed scenario to tests/integration/tests_playwright/test_hydration_storage.py. The synced page already has SharedNameState. Add a second synced var with the shared name, click a button that sets one of them, and expect both texts to update in the same tab.

This harness replays deltas in the order the test writes them. It does not see the backend's event order, which is where the CI regression shows.


Generated by Claude Code

Skip the sibling sync when any synced var sharing the storage name has
an unechoed entry in sentStorageValues, so a stale delta cannot resend
an older value while a newer update_vars_internal is in flight. Adds
the Playwright regression test requested in review.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/reflex-base/src/reflex_base/.templates/web/utils/state.js">

<violation number="1" location="packages/reflex-base/src/reflex_base/.templates/web/utils/state.js:1457">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

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
!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.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on deck PRs lined up to review / merge next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Synced storage vars sharing one name stay stale in the tab that writes

3 participants