Skip to content

Commit f10cff4

Browse files
fix: sync every storage var that shares a name across tabs (#7545)
Co-authored-by: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com>
1 parent 2f71cdf commit f10cff4

3 files changed

Lines changed: 95 additions & 11 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Editing one tab no longer leaves other `sync=True` browser storage vars that share its storage `name` stale in other tabs; every such var is now synced, not only the last one declared.

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -458,13 +458,15 @@ function urlFrom(string) {
458458
*
459459
* The value is read when the event is sent, so it and the write count recorded
460460
* with it come from the same moment.
461-
* @param payload The storage name and state key of the var.
461+
* @param payload The storage name and the state keys of the vars that use it.
462462
* @returns The update_vars_internal event.
463463
*/
464-
const syncLocalStorageEvent = ({ key, state_key }) =>
465-
ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, {
466-
vars: { [state_key]: localStorage.getItem(key) },
464+
const syncLocalStorageEvent = ({ key, state_keys }) => {
465+
const value = localStorage.getItem(key);
466+
return ReflexEvent(`${app.state_name}.${UPDATE_VARS_INTERNAL}`, {
467+
vars: Object.fromEntries(state_keys.map((state_key) => [state_key, value])),
467468
});
469+
};
468470

469471
/**
470472
* Handle frontend event or send the event to the backend via Websocket.
@@ -1522,26 +1524,33 @@ export const useEventLoop = (
15221524

15231525
// localStorage event handling
15241526
useEffect(() => {
1525-
const storage_to_state_map = {};
1527+
// A Map, so a storage name like "constructor" is not an inherited property.
1528+
const storage_to_state_map = new Map();
15261529

15271530
if (client_storage.local_storage && typeof window !== "undefined") {
15281531
for (const state_key in client_storage.local_storage) {
15291532
const options = client_storage.local_storage[state_key];
15301533
if (options.sync) {
15311534
const local_storage_value_key = options.name || state_key;
1532-
storage_to_state_map[local_storage_value_key] = state_key;
1535+
// Several vars can share one storage name; sync all of them.
1536+
const state_keys = storage_to_state_map.get(local_storage_value_key);
1537+
if (state_keys) {
1538+
state_keys.push(state_key);
1539+
} else {
1540+
storage_to_state_map.set(local_storage_value_key, [state_key]);
1541+
}
15331542
}
15341543
}
15351544
}
15361545

15371546
// e is StorageEvent
15381547
const handleStorage = (e) => {
1539-
const state_key = storage_to_state_map[e.key];
1548+
const state_keys = storage_to_state_map.get(e.key);
15401549
// Session storage changes in same-origin frames raise storage events too.
1541-
if (state_key && e.storageArea === localStorage) {
1550+
if (state_keys && e.storageArea === localStorage) {
15421551
// The value is read when the event is sent: see syncLocalStorageEvent.
15431552
addEvents(
1544-
[ReflexEvent(SYNC_LOCAL_STORAGE, { key: e.key, state_key })],
1553+
[ReflexEvent(SYNC_LOCAL_STORAGE, { key: e.key, state_keys })],
15451554
e,
15461555
);
15471556
}

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ async function setup({
1818
disabled = false,
1919
hidden = false,
2020
storedToken = "original-token",
21+
clientStorage = {},
2122
react = { useCallback() {}, useEffect() {}, useRef() {}, useState() {} },
2223
} = {}) {
2324
const sockets = [];
@@ -32,6 +33,12 @@ async function setup({
3233
getItem: (key) => storage.get(key),
3334
setItem: (key, value) => storage.set(key, value),
3435
};
36+
const localStorage = {
37+
items: new Map(),
38+
getItem(key) {
39+
return this.items.get(key) ?? null;
40+
},
41+
};
3542
const document = new EventTarget();
3643
document.cookie = disabled ? "backend-enabled=false" : "";
3744
document.visibilityState = hidden ? "hidden" : "visible";
@@ -41,6 +48,7 @@ async function setup({
4148
URL,
4249
URLSearchParams,
4350
performance,
51+
localStorage,
4452
...(browser ? { window, document } : {}),
4553
queueMicrotask: (fn) => microtasks.push(fn),
4654
setTimeout: (fn, ms) => {
@@ -127,6 +135,7 @@ async function setup({
127135
initialState: stateful ? { root: {}, child: {} } : {},
128136
onLoadInternalEvent() {},
129137
state_name: "root",
138+
clientStorage,
130139
exception_state_name: "exception",
131140
},
132141
eventLoop: { addEvents() {}, connectErrors: [] },
@@ -164,6 +173,7 @@ async function setup({
164173
timers,
165174
firstHydrates,
166175
window,
176+
localStorage,
167177
socket,
168178
updates,
169179
module,
@@ -314,7 +324,7 @@ for (const config of [
314324
}
315325

316326
/** Run `useEventLoop` once with minimal hooks, keeping the effects it registers. */
317-
async function mountEventLoop() {
327+
async function mountEventLoop(clientStorage = {}) {
318328
const effects = [];
319329
const sent = [];
320330
const emitted = {
@@ -323,6 +333,7 @@ async function mountEventLoop() {
323333
};
324334
let refs = 0;
325335
const harness = await setup({
336+
clientStorage,
326337
react: {
327338
useCallback: (fn) => fn,
328339
useEffect: (fn) => effects.push(fn),
@@ -334,7 +345,7 @@ async function mountEventLoop() {
334345
harness.module.namespace.useEventLoop(
335346
() => {},
336347
() => [],
337-
{},
348+
clientStorage,
338349
);
339350
return { harness, effects, sent };
340351
}
@@ -393,3 +404,66 @@ test("window.onerror preserves the original Error message", async () => {
393404
);
394405
assert.equal(event.payload.component_stack, "");
395406
});
407+
408+
test("a storage event syncs every synced var that shares the storage name", async () => {
409+
const clientStorage = {
410+
local_storage: {
411+
first: { name: "shared", sync: true },
412+
second: { name: "shared", sync: true },
413+
other: { name: "other", sync: true },
414+
unsynced: { name: "shared" },
415+
},
416+
};
417+
const { harness, effects, sent } = await mountEventLoop(clientStorage);
418+
for (const effect of effects) effect();
419+
harness.localStorage.items.set("shared", "stored");
420+
421+
const event = new Event("storage");
422+
event.key = "shared";
423+
event.storageArea = harness.localStorage;
424+
harness.window.dispatchEvent(event);
425+
await new Promise((resolve) => setImmediate(resolve));
426+
427+
const updates = sent.filter((e) => e.name.endsWith("update_vars_internal"));
428+
assert.equal(updates.length, 1, JSON.stringify(sent));
429+
// The vars come from the vm's realm, so compare their serialised form.
430+
assert.equal(
431+
JSON.stringify(updates[0].payload.vars),
432+
JSON.stringify({ first: "stored", second: "stored" }),
433+
);
434+
});
435+
436+
test("storage names that are Object.prototype properties sync like any other", async () => {
437+
const clientStorage = {
438+
local_storage: {
439+
first: { name: "constructor", sync: true },
440+
second: { name: "constructor", sync: true },
441+
third: { name: "__proto__", sync: true },
442+
},
443+
};
444+
const { harness, effects, sent } = await mountEventLoop(clientStorage);
445+
assert.doesNotThrow(() => {
446+
for (const effect of effects) effect();
447+
});
448+
harness.localStorage.items.set("constructor", "stored");
449+
450+
for (const key of ["constructor", "toString", "__proto__"]) {
451+
const event = new Event("storage");
452+
event.key = key;
453+
event.storageArea = harness.localStorage;
454+
harness.window.dispatchEvent(event);
455+
}
456+
await new Promise((resolve) => setImmediate(resolve));
457+
458+
// Only the names a var declared are synced, and a name's vars all are.
459+
const updates = sent.filter((e) => e.name.endsWith("update_vars_internal"));
460+
assert.equal(updates.length, 2, JSON.stringify(sent));
461+
assert.equal(
462+
JSON.stringify(updates[0].payload.vars),
463+
JSON.stringify({ first: "stored", second: "stored" }),
464+
);
465+
assert.equal(
466+
JSON.stringify(updates[1].payload.vars),
467+
JSON.stringify({ third: null }),
468+
);
469+
});

0 commit comments

Comments
 (0)