Skip to content

Commit 65d1429

Browse files
fix(hooks): back useSettings with shared state and storage event
1 parent 6f02d20 commit 65d1429

2 files changed

Lines changed: 116 additions & 29 deletions

File tree

frontend/src/hooks/useSettings.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
useSettings,
55
STORAGE_KEYS,
66
formatAmountWithPreference,
7-
DEFAULT_SETTINGS
7+
DEFAULT_SETTINGS,
8+
_resetSharedSettings
89
} from './useSettings';
910

1011
const localStorageMock = (() => {
@@ -29,6 +30,7 @@ describe('useSettings and formatAmountWithPreference', () => {
2930
beforeEach(() => {
3031
localStorage.clear();
3132
document.documentElement.className = '';
33+
_resetSharedSettings();
3234
});
3335

3436
afterEach(() => {
@@ -119,6 +121,43 @@ describe('useSettings and formatAmountWithPreference', () => {
119121
expect(result.current.amountFormat).toBe('compact');
120122
expect(localStorage.getItem(STORAGE_KEYS.amountFormat)).toBe('compact');
121123
});
124+
125+
it('syncs state across multiple consumers without remount', async () => {
126+
const { result: consumerA } = renderHook(() => useSettings());
127+
const { result: consumerB } = renderHook(() => useSettings());
128+
129+
await waitFor(() => {
130+
expect(consumerA.current.isHydrated).toBe(true);
131+
expect(consumerB.current.isHydrated).toBe(true);
132+
});
133+
134+
act(() => {
135+
consumerA.current.setDecimalPlaces(2);
136+
});
137+
138+
expect(consumerA.current.decimalPlaces).toBe(2);
139+
expect(consumerB.current.decimalPlaces).toBe(2);
140+
});
141+
142+
it('syncs state across tabs using storage event', async () => {
143+
const { result } = renderHook(() => useSettings());
144+
145+
await waitFor(() => expect(result.current.isHydrated).toBe(true));
146+
147+
act(() => {
148+
// Simulate other tab changing local storage
149+
localStorage.setItem(STORAGE_KEYS.theme, 'light');
150+
151+
// Dispatch storage event
152+
const event = new StorageEvent('storage', {
153+
key: STORAGE_KEYS.theme,
154+
newValue: 'light'
155+
});
156+
window.dispatchEvent(event);
157+
});
158+
159+
expect(result.current.theme).toBe('light');
160+
});
122161
});
123162

124163
describe('formatAmountWithPreference', () => {

frontend/src/hooks/useSettings.ts

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,39 +28,84 @@ const STORAGE_KEYS = {
2828
decimalPlaces: "flowfi-decimal-places",
2929
};
3030

31+
let sharedSettings: Settings = { ...DEFAULT_SETTINGS };
32+
let sharedIsHydrated = false;
33+
const listeners = new Set<() => void>();
34+
35+
function notifyListeners() {
36+
listeners.forEach((listener) => listener());
37+
}
38+
39+
function loadSettingsFromStorage(): Settings {
40+
if (typeof window === "undefined") return { ...DEFAULT_SETTINGS };
41+
const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null;
42+
const savedCurrency = localStorage.getItem(
43+
STORAGE_KEYS.displayCurrency
44+
) as DisplayCurrency | null;
45+
const savedFormat = localStorage.getItem(
46+
STORAGE_KEYS.amountFormat
47+
) as AmountFormat | null;
48+
const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces);
49+
50+
return {
51+
theme: savedTheme || DEFAULT_SETTINGS.theme,
52+
displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency,
53+
amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat,
54+
decimalPlaces: savedDecimals
55+
? (parseInt(savedDecimals, 10) as DecimalPlaces)
56+
: DEFAULT_SETTINGS.decimalPlaces,
57+
};
58+
}
59+
60+
if (typeof window !== "undefined") {
61+
window.addEventListener("storage", (e) => {
62+
if (!e.key || Object.values(STORAGE_KEYS).includes(e.key)) {
63+
sharedSettings = loadSettingsFromStorage();
64+
sharedIsHydrated = true;
65+
notifyListeners();
66+
}
67+
});
68+
}
69+
70+
// For testing purposes
71+
export function _resetSharedSettings() {
72+
sharedSettings = { ...DEFAULT_SETTINGS };
73+
sharedIsHydrated = false;
74+
listeners.clear();
75+
}
76+
3177
export function useSettings() {
32-
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
33-
const [isHydrated, setIsHydrated] = useState(false);
78+
const [settings, setSettingsState] = useState<Settings>(sharedSettings);
79+
const [isHydrated, setIsHydrated] = useState(sharedIsHydrated);
3480

3581
useEffect(() => {
36-
if (typeof window === "undefined") return;
37-
38-
const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null;
39-
const savedCurrency = localStorage.getItem(
40-
STORAGE_KEYS.displayCurrency
41-
) as DisplayCurrency | null;
42-
const savedFormat = localStorage.getItem(
43-
STORAGE_KEYS.amountFormat
44-
) as AmountFormat | null;
45-
const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces);
46-
47-
// Use queueMicrotask to avoid synchronous setState in effect
48-
queueMicrotask(() => {
49-
setSettings({
50-
theme: savedTheme || DEFAULT_SETTINGS.theme,
51-
displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency,
52-
amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat,
53-
decimalPlaces: savedDecimals
54-
? (parseInt(savedDecimals, 10) as DecimalPlaces)
55-
: DEFAULT_SETTINGS.decimalPlaces,
82+
const listener = () => {
83+
setSettingsState(sharedSettings);
84+
setIsHydrated(sharedIsHydrated);
85+
};
86+
listeners.add(listener);
87+
88+
if (!sharedIsHydrated && typeof window !== "undefined") {
89+
queueMicrotask(() => {
90+
if (!sharedIsHydrated) {
91+
sharedSettings = loadSettingsFromStorage();
92+
sharedIsHydrated = true;
93+
notifyListeners();
94+
}
5695
});
57-
setIsHydrated(true);
58-
});
96+
} else {
97+
listener();
98+
}
99+
100+
return () => {
101+
listeners.delete(listener);
102+
};
59103
}, []);
60104

61105
const setTheme = useCallback((theme: Theme) => {
62-
setSettings((prev) => ({ ...prev, theme }));
106+
sharedSettings = { ...sharedSettings, theme };
63107
localStorage.setItem(STORAGE_KEYS.theme, theme);
108+
notifyListeners();
64109

65110
// Apply theme immediately
66111
if (theme === "system") {
@@ -72,18 +117,21 @@ export function useSettings() {
72117
}, []);
73118

74119
const setDisplayCurrency = useCallback((currency: DisplayCurrency) => {
75-
setSettings((prev) => ({ ...prev, displayCurrency: currency }));
120+
sharedSettings = { ...sharedSettings, displayCurrency: currency };
76121
localStorage.setItem(STORAGE_KEYS.displayCurrency, currency);
122+
notifyListeners();
77123
}, []);
78124

79125
const setAmountFormat = useCallback((format: AmountFormat) => {
80-
setSettings((prev) => ({ ...prev, amountFormat: format }));
126+
sharedSettings = { ...sharedSettings, amountFormat: format };
81127
localStorage.setItem(STORAGE_KEYS.amountFormat, format);
128+
notifyListeners();
82129
}, []);
83130

84131
const setDecimalPlaces = useCallback((places: DecimalPlaces) => {
85-
setSettings((prev) => ({ ...prev, decimalPlaces: places }));
132+
sharedSettings = { ...sharedSettings, decimalPlaces: places };
86133
localStorage.setItem(STORAGE_KEYS.decimalPlaces, places.toString());
134+
notifyListeners();
87135
}, []);
88136

89137
return {

0 commit comments

Comments
 (0)