Skip to content

Commit d931987

Browse files
committed
fix: sync shared-name storage vars in the writing tab (#7567)
1 parent 74bcd8b commit d931987

3 files changed

Lines changed: 211 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Synced `LocalStorage` vars that share one storage `name` now update in the tab that writes, not only in other tabs.

‎packages/reflex-base/src/reflex_base/.templates/web/utils/state.js‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,7 +1172,18 @@ export const connect = async (
11721172
for (const substate in delta) {
11731173
dispatch[substate](delta[substate]);
11741174
}
1175-
applyClientStorageDelta(client_storage, delta);
1175+
const pending_syncs = applyClientStorageDelta(client_storage, delta);
1176+
if (pending_syncs.length > 0) {
1177+
queueEvents(
1178+
pending_syncs.map(({ key, state_keys }) =>
1179+
ReflexEvent(SYNC_LOCAL_STORAGE, { key, state_keys }),
1180+
),
1181+
socket,
1182+
false,
1183+
navigate,
1184+
params,
1185+
).catch(reportStateUpdateError);
1186+
}
11761187
}
11771188
if (update.events && update.events.length > 0) {
11781189
queueEvents(update.events, socket, false, navigate, params).catch(
@@ -1363,8 +1374,13 @@ const takeEcho = (state_key, value) => {
13631374
* Update client storage values based on backend state delta.
13641375
* @param client_storage The client storage object from context.js
13651376
* @param delta The state update from the backend
1377+
* @returns Storage syncs to send for this tab's sibling vars sharing a
1378+
* written storage name, as [{key, state_keys}]. The storage event reaches
1379+
* other tabs only, so same-tab siblings are synced from the write here.
13661380
*/
13671381
const applyClientStorageDelta = (client_storage, delta) => {
1382+
// Syncs to send, keyed by storage name so one name syncs once per delta.
1383+
const pending_syncs = new Map();
13681384
// find the main state and check for is_hydrated
13691385
const unqualified_states = Object.keys(delta).filter(
13701386
(key) => key.split(".").length === 1,
@@ -1377,7 +1393,7 @@ const applyClientStorageDelta = (client_storage, delta) => {
13771393
) {
13781394
// skip if the state is not hydrated yet, since all client storage
13791395
// values are sent in the hydrate event
1380-
return;
1396+
return [];
13811397
}
13821398
}
13831399
// Save known client storage values to cookies and localStorage.
@@ -1395,7 +1411,8 @@ const applyClientStorageDelta = (client_storage, delta) => {
13951411
state_key in client_storage.local_storage &&
13961412
typeof window !== "undefined"
13971413
) {
1398-
const name = client_storage.local_storage[state_key].name || state_key;
1414+
const options = client_storage.local_storage[state_key];
1415+
const name = options.name || state_key;
13991416
const echo = takeEcho(state_key, value);
14001417
const last = lastLocalStorageWrites[name];
14011418
// Write an echo only over this tab's own later write, which the backend
@@ -1406,11 +1423,31 @@ const applyClientStorageDelta = (client_storage, delta) => {
14061423
(last?.writes > echo.writes &&
14071424
localStorage.getItem(name) === last.value)
14081425
) {
1426+
const previous = localStorage.getItem(name);
14091427
localStorage.setItem(name, value);
14101428
lastLocalStorageWrites[name] = {
14111429
value,
14121430
writes: ++localStorageWrites,
14131431
};
1432+
// The storage event fires in other tabs only, so queue the sync for
1433+
// this tab's other synced vars sharing the name here, whether or
1434+
// not the written var itself syncs (storage writes raise the event
1435+
// cross-tab either way). Skip an unchanged value: its vars are
1436+
// already in sync, and re-sending would ping-pong when an echo
1437+
// misses. Deltas are per-session, and a sent update echoes back and
1438+
// takeEcho keeps it from writing again.
1439+
if (previous !== String(value) && !pending_syncs.has(name)) {
1440+
const state_keys = Object.keys(client_storage.local_storage).filter(
1441+
(other_key) =>
1442+
other_key !== state_key &&
1443+
client_storage.local_storage[other_key].sync &&
1444+
(client_storage.local_storage[other_key].name || other_key) ===
1445+
name,
1446+
);
1447+
if (state_keys.length > 0) {
1448+
pending_syncs.set(name, state_keys);
1449+
}
1450+
}
14141451
}
14151452
} else if (
14161453
client_storage.session_storage &&
@@ -1422,6 +1459,7 @@ const applyClientStorageDelta = (client_storage, delta) => {
14221459
}
14231460
}
14241461
}
1462+
return [...pending_syncs].map(([key, state_keys]) => ({ key, state_keys }));
14251463
};
14261464

14271465
/**

‎tests/units/compiler/state_js.test.mjs‎

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async function setup({
3232
const timers = new Map();
3333
const firstHydrates = [];
3434
const updates = [];
35+
const emits = [];
3536
const consoleErrors = [];
3637
let connectErrors = [];
3738
const errorUpdates = [];
@@ -47,6 +48,9 @@ async function setup({
4748
getItem(key) {
4849
return this.items.get(key) ?? null;
4950
},
51+
setItem(key, value) {
52+
this.items.set(key, String(value));
53+
},
5054
};
5155
const document = new EventTarget();
5256
document.cookie = disabled ? "backend-enabled=false" : "";
@@ -107,6 +111,9 @@ async function setup({
107111
on(name, handler) {
108112
handlers.set(name, handler);
109113
},
114+
emit(name, payload) {
115+
emits.push(payload);
116+
},
110117
connect() {
111118
// A ready transport can deliver these synchronously on namespace connect.
112119
for (const name of [
@@ -226,6 +233,7 @@ async function setup({
226233
localStorage,
227234
socket,
228235
updates,
236+
emits,
229237
consoleErrors,
230238
get connectErrors() {
231239
// Like React with pending work, apply queued updaters only on render.
@@ -238,13 +246,13 @@ async function setup({
238246
module,
239247
dispose,
240248
flush: () => microtasks.splice(0).forEach((fn) => fn()),
241-
connect: (transports = ["websocket"]) =>
249+
connect: (transports = ["websocket"], storage = {}) =>
242250
module.namespace.connect(
243251
socket,
244252
{ child: (delta) => updates.push(delta.count) },
245253
transports,
246254
(update) => errorUpdates.push(update),
247-
{},
255+
storage,
248256
() => {},
249257
{ current: {} },
250258
),
@@ -724,3 +732,162 @@ test("storage names that are Object.prototype properties sync like any other", a
724732
JSON.stringify({ third: null }),
725733
);
726734
});
735+
736+
test("a synced storage write syncs the writing tab's sibling vars", async () => {
737+
const clientStorage = {
738+
local_storage: {
739+
"child.first": { name: "shared", sync: true },
740+
"child.second": { name: "shared", sync: true },
741+
"child.third": { name: "shared", sync: true },
742+
"child.unsynced": { name: "shared" },
743+
},
744+
};
745+
const app = await setup({ clientStorage });
746+
app.connect(["websocket"], clientStorage);
747+
app.flush();
748+
const sock = app.sockets[0];
749+
const syncs = () =>
750+
app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal"));
751+
752+
// The backend applies a write to one synced var.
753+
sock.handlers.get("event")({ delta: { child: { first: "from-cs0" } } });
754+
app.flush();
755+
await new Promise((resolve) => setImmediate(resolve));
756+
757+
// The storage event only fires in other tabs, so the writing tab syncs its
758+
// own sibling vars sharing the name.
759+
assert.equal(app.localStorage.items.get("shared"), "from-cs0");
760+
assert.equal(syncs().length, 1, JSON.stringify(app.emits));
761+
assert.equal(
762+
JSON.stringify(syncs()[0].payload.vars),
763+
JSON.stringify({ "child.second": "from-cs0", "child.third": "from-cs0" }),
764+
);
765+
766+
// The echoed sibling updates neither write again nor queue another sync.
767+
sock.handlers.get("event")({
768+
delta: { child: { second: "from-cs0", third: "from-cs0" } },
769+
});
770+
app.flush();
771+
await new Promise((resolve) => setImmediate(resolve));
772+
assert.equal(syncs().length, 1, JSON.stringify(app.emits));
773+
});
774+
775+
test("an unsynced write still syncs its synced siblings in the writing tab", async () => {
776+
const clientStorage = {
777+
local_storage: {
778+
"child.first": { name: "shared", sync: true },
779+
"child.second": { name: "shared" },
780+
},
781+
};
782+
const app = await setup({ clientStorage });
783+
app.connect(["websocket"], clientStorage);
784+
app.flush();
785+
const sock = app.sockets[0];
786+
const syncs = () =>
787+
app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal"));
788+
789+
// Storage writes raise the cross-tab event whether or not the written var
790+
// syncs, so the writing tab syncs its synced siblings either way.
791+
sock.handlers.get("event")({ delta: { child: { second: "plain" } } });
792+
app.flush();
793+
await new Promise((resolve) => setImmediate(resolve));
794+
795+
assert.equal(app.localStorage.items.get("shared"), "plain");
796+
assert.equal(syncs().length, 1, JSON.stringify(app.emits));
797+
assert.equal(
798+
JSON.stringify(syncs()[0].payload.vars),
799+
JSON.stringify({ "child.first": "plain" }),
800+
);
801+
});
802+
803+
test("two same-name vars in one delta queue one sync per storage name", async () => {
804+
const clientStorage = {
805+
local_storage: {
806+
"child.first": { name: "shared", sync: true },
807+
"child.second": { name: "shared", sync: true },
808+
"child.third": { name: "other", sync: true },
809+
"child.fourth": { name: "other", sync: true },
810+
},
811+
};
812+
const app = await setup({ clientStorage });
813+
app.connect(["websocket"], clientStorage);
814+
app.flush();
815+
const sock = app.sockets[0];
816+
const syncs = () =>
817+
app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal"));
818+
819+
sock.handlers.get("event")({
820+
delta: { child: { first: "one", second: "two", third: "three" } },
821+
});
822+
app.flush();
823+
await new Promise((resolve) => setImmediate(resolve));
824+
825+
// One sync per storage name, reading the name's latest value when sent.
826+
assert.equal(syncs().length, 2, JSON.stringify(app.emits));
827+
const byVars = syncs().map((e) => JSON.stringify(e.payload.vars));
828+
assert.ok(byVars.includes(JSON.stringify({ "child.second": "two" })));
829+
assert.ok(byVars.includes(JSON.stringify({ "child.fourth": "three" })));
830+
});
831+
832+
test("an unhydrated delta writes and syncs no client storage", async () => {
833+
const clientStorage = {
834+
local_storage: {
835+
"child.first": { name: "shared", sync: true },
836+
"child.second": { name: "shared", sync: true },
837+
},
838+
};
839+
const app = await setup({ clientStorage });
840+
app.connect(["websocket"], clientStorage);
841+
app.flush();
842+
const sock = app.sockets[0];
843+
844+
sock.handlers.get("event")({
845+
delta: { child: { is_hydrated_rx_state_: false, first: "early" } },
846+
});
847+
app.flush();
848+
await new Promise((resolve) => setImmediate(resolve));
849+
850+
assert.equal(app.localStorage.items.get("shared"), undefined);
851+
assert.equal(
852+
app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal")).length,
853+
0,
854+
JSON.stringify(app.emits),
855+
);
856+
});
857+
858+
test("a newer backend value re-syncs once, then the echo terminates it", async () => {
859+
const clientStorage = {
860+
local_storage: {
861+
"child.first": { name: "shared", sync: true },
862+
"child.second": { name: "shared", sync: true },
863+
},
864+
};
865+
const app = await setup({ clientStorage });
866+
app.connect(["websocket"], clientStorage);
867+
app.flush();
868+
const sock = app.sockets[0];
869+
const syncs = () =>
870+
app.emits.filter((e) => e?.name?.endsWith?.("update_vars_internal"));
871+
const deliver = async (delta) => {
872+
sock.handlers.get("event")({ delta });
873+
app.flush();
874+
await new Promise((resolve) => setImmediate(resolve));
875+
};
876+
877+
await deliver({ child: { first: "v1" } });
878+
assert.equal(syncs().length, 1, JSON.stringify(app.emits));
879+
880+
// A backend value that matches no sent value overrides storage and syncs
881+
// the siblings once more.
882+
await deliver({ child: { second: "v2" } });
883+
assert.equal(app.localStorage.items.get("shared"), "v2");
884+
assert.equal(syncs().length, 2, JSON.stringify(app.emits));
885+
assert.equal(
886+
JSON.stringify(syncs()[1].payload.vars),
887+
JSON.stringify({ "child.first": "v2" }),
888+
);
889+
890+
// That update's echo is recognized: no write, no third sync. No ping-pong.
891+
await deliver({ child: { first: "v2" } });
892+
assert.equal(syncs().length, 2, JSON.stringify(app.emits));
893+
});

0 commit comments

Comments
 (0)