From 03d0cde0221fe8db16ec97c6a7cfd87ab8e33b8f Mon Sep 17 00:00:00 2001 From: Dreamland1 Date: Mon, 29 Jun 2026 22:04:35 +0100 Subject: [PATCH 1/4] fix(hooks): back useSettings with shared state and storage event --- frontend/src/hooks/useSettings.test.ts | 41 +++++++++- frontend/src/hooks/useSettings.ts | 104 ++++++++++++++++++------- 2 files changed, 116 insertions(+), 29 deletions(-) diff --git a/frontend/src/hooks/useSettings.test.ts b/frontend/src/hooks/useSettings.test.ts index ccee1e84..f3efa01e 100644 --- a/frontend/src/hooks/useSettings.test.ts +++ b/frontend/src/hooks/useSettings.test.ts @@ -4,7 +4,8 @@ import { useSettings, STORAGE_KEYS, formatAmountWithPreference, - DEFAULT_SETTINGS + DEFAULT_SETTINGS, + _resetSharedSettings } from './useSettings'; const localStorageMock = (() => { @@ -29,6 +30,7 @@ describe('useSettings and formatAmountWithPreference', () => { beforeEach(() => { localStorage.clear(); document.documentElement.className = ''; + _resetSharedSettings(); }); afterEach(() => { @@ -119,6 +121,43 @@ describe('useSettings and formatAmountWithPreference', () => { expect(result.current.amountFormat).toBe('compact'); expect(localStorage.getItem(STORAGE_KEYS.amountFormat)).toBe('compact'); }); + + it('syncs state across multiple consumers without remount', async () => { + const { result: consumerA } = renderHook(() => useSettings()); + const { result: consumerB } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(consumerA.current.isHydrated).toBe(true); + expect(consumerB.current.isHydrated).toBe(true); + }); + + act(() => { + consumerA.current.setDecimalPlaces(2); + }); + + expect(consumerA.current.decimalPlaces).toBe(2); + expect(consumerB.current.decimalPlaces).toBe(2); + }); + + it('syncs state across tabs using storage event', async () => { + const { result } = renderHook(() => useSettings()); + + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + act(() => { + // Simulate other tab changing local storage + localStorage.setItem(STORAGE_KEYS.theme, 'light'); + + // Dispatch storage event + const event = new StorageEvent('storage', { + key: STORAGE_KEYS.theme, + newValue: 'light' + }); + window.dispatchEvent(event); + }); + + expect(result.current.theme).toBe('light'); + }); }); describe('formatAmountWithPreference', () => { diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 822301d8..12491300 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -28,39 +28,84 @@ const STORAGE_KEYS = { decimalPlaces: "flowfi-decimal-places", }; +let sharedSettings: Settings = { ...DEFAULT_SETTINGS }; +let sharedIsHydrated = false; +const listeners = new Set<() => void>(); + +function notifyListeners() { + listeners.forEach((listener) => listener()); +} + +function loadSettingsFromStorage(): Settings { + if (typeof window === "undefined") return { ...DEFAULT_SETTINGS }; + const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null; + const savedCurrency = localStorage.getItem( + STORAGE_KEYS.displayCurrency + ) as DisplayCurrency | null; + const savedFormat = localStorage.getItem( + STORAGE_KEYS.amountFormat + ) as AmountFormat | null; + const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces); + + return { + theme: savedTheme || DEFAULT_SETTINGS.theme, + displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency, + amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat, + decimalPlaces: savedDecimals + ? (parseInt(savedDecimals, 10) as DecimalPlaces) + : DEFAULT_SETTINGS.decimalPlaces, + }; +} + +if (typeof window !== "undefined") { + window.addEventListener("storage", (e) => { + if (!e.key || Object.values(STORAGE_KEYS).includes(e.key)) { + sharedSettings = loadSettingsFromStorage(); + sharedIsHydrated = true; + notifyListeners(); + } + }); +} + +// For testing purposes +export function _resetSharedSettings() { + sharedSettings = { ...DEFAULT_SETTINGS }; + sharedIsHydrated = false; + listeners.clear(); +} + export function useSettings() { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [isHydrated, setIsHydrated] = useState(false); + const [settings, setSettingsState] = useState(sharedSettings); + const [isHydrated, setIsHydrated] = useState(sharedIsHydrated); useEffect(() => { - if (typeof window === "undefined") return; - - const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null; - const savedCurrency = localStorage.getItem( - STORAGE_KEYS.displayCurrency - ) as DisplayCurrency | null; - const savedFormat = localStorage.getItem( - STORAGE_KEYS.amountFormat - ) as AmountFormat | null; - const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces); - - // Use queueMicrotask to avoid synchronous setState in effect - queueMicrotask(() => { - setSettings({ - theme: savedTheme || DEFAULT_SETTINGS.theme, - displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency, - amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat, - decimalPlaces: savedDecimals - ? (parseInt(savedDecimals, 10) as DecimalPlaces) - : DEFAULT_SETTINGS.decimalPlaces, + const listener = () => { + setSettingsState(sharedSettings); + setIsHydrated(sharedIsHydrated); + }; + listeners.add(listener); + + if (!sharedIsHydrated && typeof window !== "undefined") { + queueMicrotask(() => { + if (!sharedIsHydrated) { + sharedSettings = loadSettingsFromStorage(); + sharedIsHydrated = true; + notifyListeners(); + } }); - setIsHydrated(true); - }); + } else { + listener(); + } + + return () => { + listeners.delete(listener); + }; }, []); const setTheme = useCallback((theme: Theme) => { - setSettings((prev) => ({ ...prev, theme })); + sharedSettings = { ...sharedSettings, theme }; localStorage.setItem(STORAGE_KEYS.theme, theme); + notifyListeners(); // Apply theme immediately if (theme === "system") { @@ -72,18 +117,21 @@ export function useSettings() { }, []); const setDisplayCurrency = useCallback((currency: DisplayCurrency) => { - setSettings((prev) => ({ ...prev, displayCurrency: currency })); + sharedSettings = { ...sharedSettings, displayCurrency: currency }; localStorage.setItem(STORAGE_KEYS.displayCurrency, currency); + notifyListeners(); }, []); const setAmountFormat = useCallback((format: AmountFormat) => { - setSettings((prev) => ({ ...prev, amountFormat: format })); + sharedSettings = { ...sharedSettings, amountFormat: format }; localStorage.setItem(STORAGE_KEYS.amountFormat, format); + notifyListeners(); }, []); const setDecimalPlaces = useCallback((places: DecimalPlaces) => { - setSettings((prev) => ({ ...prev, decimalPlaces: places })); + sharedSettings = { ...sharedSettings, decimalPlaces: places }; localStorage.setItem(STORAGE_KEYS.decimalPlaces, places.toString()); + notifyListeners(); }, []); return { From 558dc048cb82b7400c4ac110f0b42b452b0bc092 Mon Sep 17 00:00:00 2001 From: Dreamland1 Date: Mon, 29 Jun 2026 22:08:34 +0100 Subject: [PATCH 2/4] test(hooks): add unit tests for useIncomingStreams --- frontend/src/hooks/useIncomingStreams.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 frontend/src/hooks/useIncomingStreams.test.ts diff --git a/frontend/src/hooks/useIncomingStreams.test.ts b/frontend/src/hooks/useIncomingStreams.test.ts new file mode 100644 index 00000000..39a9b9d8 --- /dev/null +++ b/frontend/src/hooks/useIncomingStreams.test.ts @@ -0,0 +1,113 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import React from "react"; +import { + useIncomingStreams, + useWithdrawIncomingStream, + incomingStreamsQueryKey, +} from "./useIncomingStreams"; +import { fetchIncomingStreams } from "@/lib/api/streams"; +import { withdrawFromStream } from "@/lib/soroban"; + +vi.mock("@/lib/api/streams", () => ({ + fetchIncomingStreams: vi.fn(), +})); + +vi.mock("@/lib/soroban", () => ({ + withdrawFromStream: vi.fn(), +})); + +describe("useIncomingStreams hooks", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe("incomingStreamsQueryKey", () => { + it("returns correct shape", () => { + expect(incomingStreamsQueryKey("pubkey")).toEqual([ + "incoming-streams", + "pubkey", + ]); + expect(incomingStreamsQueryKey(null)).toEqual(["incoming-streams", null]); + }); + }); + + describe("useIncomingStreams", () => { + it("stays disabled when publicKey is null/undefined", () => { + const { result, rerender } = renderHook( + (props: { publicKey: string | null | undefined }) => + useIncomingStreams(props.publicKey), + { wrapper, initialProps: { publicKey: null } } + ); + + expect(result.current.isPending).toBe(true); + expect(result.current.fetchStatus).toBe("idle"); + expect(fetchIncomingStreams).not.toHaveBeenCalled(); + + rerender({ publicKey: undefined }); + expect(result.current.fetchStatus).toBe("idle"); + expect(fetchIncomingStreams).not.toHaveBeenCalled(); + }); + }); + + describe("useWithdrawIncomingStream", () => { + it("rejects when session is null", async () => { + const { result } = renderHook( + () => useWithdrawIncomingStream(null, "pubkey"), + { wrapper } + ); + + await expect( + result.current.mutateAsync({} as any) + ).rejects.toThrow("Please connect your wallet first"); + expect(withdrawFromStream).not.toHaveBeenCalled(); + }); + + it("invalidates incomingStreamsQueryKey(publicKey) on success", async () => { + (withdrawFromStream as any).mockResolvedValue({ status: "success" }); + (fetchIncomingStreams as any).mockResolvedValue([]); + + const { result } = renderHook( + () => useWithdrawIncomingStream({} as any, "pubkey"), + { wrapper } + ); + + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + await act(async () => { + await result.current.mutateAsync({ + id: 1, + streamId: 1, + withdrawn: 0, + deposited: 100, + ratePerSecond: 1, + isPaused: false, + lastUpdateTime: Date.now() / 1000, + } as any); + }); + + // Wait for pollIndexerForWithdraw to complete and call invalidateQueries + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: incomingStreamsQueryKey("pubkey"), + }); + }, { timeout: 10000 }); + }); + }); +}); From f4cc01b307004d3c71dd92dc8f14de610eea10f5 Mon Sep 17 00:00:00 2001 From: Dreamland1 Date: Mon, 29 Jun 2026 22:12:08 +0100 Subject: [PATCH 3/4] fix(a11y): add aria attributes to TokenStep --- .../src/components/stream-creation/TokenStep.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/stream-creation/TokenStep.tsx b/frontend/src/components/stream-creation/TokenStep.tsx index 3caf8675..1ef5d97b 100644 --- a/frontend/src/components/stream-creation/TokenStep.tsx +++ b/frontend/src/components/stream-creation/TokenStep.tsx @@ -21,19 +21,26 @@ export const TokenStep: React.FC = ({ return (
-

Select Token

+

Select Token

Choose the token you want to stream to the recipient.

-
+
{TOKENS.map((token) => { const isSelected = value === token.id; return (