From 758e242aebc9e06e04e27c636b888fc468227684 Mon Sep 17 00:00:00 2001 From: onyillto <56700691+onyillto@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:56:39 +0100 Subject: [PATCH] feat(frontend): add polling fallback and reconnect logic for SSE (#577) - Fall back to setInterval polling every 30s when SSE connection fails - Add exponential backoff auto-reconnect after connection drops - Expose polling status for "Live" / "Refreshing every 30s" UI indicator - Guard against SSR by checking window.EventSource availability closes:577 --- .../components/providers/WalletProvider.tsx | 38 +++++++------- frontend/src/app/hooks/useSSE.ts | 52 +++++++++++++++++-- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/components/providers/WalletProvider.tsx b/frontend/src/app/components/providers/WalletProvider.tsx index 7fd4b686..581dfb32 100644 --- a/frontend/src/app/components/providers/WalletProvider.tsx +++ b/frontend/src/app/components/providers/WalletProvider.tsx @@ -10,8 +10,8 @@ interface WalletProviderContextValue { connectWallet: () => Promise; disconnectWallet: () => void; refreshWallet: () => Promise; - isFreighterAvailable: boolean; signTransaction: (unsignedTxXdr: string) => Promise; + isFreighterAvailable: boolean; } interface WalletProviderProps { @@ -44,6 +44,13 @@ const NETWORK_CHAIN_IDS: Record = { STANDALONE: 4, }; +const NETWORK_PASSPHRASES: Record = { + PUBLIC: "Public Global Stellar Network ; October 2015", + TESTNET: "Test SDF Network ; September 2015", + FUTURENET: "Test SDF Future Network ; October 2022", + STANDALONE: "Standalone Network ; Separate from SDF", +}; + function normalizeWalletError(error: unknown): string { if (typeof error === "string" && error.length > 0) { return error; @@ -96,6 +103,7 @@ async function loadFreighterApi(): Promise { export function WalletProvider({ children }: WalletProviderProps) { const address = useWalletStore((state) => state.address); + const network = useWalletStore((state) => state.network); const shouldAutoReconnect = useWalletStore((state) => state.shouldAutoReconnect); const setConnected = useWalletStore((state) => state.setConnected); const disconnect = useWalletStore((state) => state.disconnect); @@ -219,21 +227,11 @@ export function WalletProvider({ children }: WalletProviderProps) { await syncWallet(true); } - function disconnectWallet() { - disconnect(); - } - - const NETWORK_PASSPHRASES: Record = { - PUBLIC: "Public Global Stellar Network ; October 2015", - TESTNET: "Test SDF Network ; September 2015", - FUTURENET: "Test SDF Future Network ; October 2022", - STANDALONE: "Standalone Network ; Separate from SDF", - }; - async function signTransaction(unsignedTxXdr: string): Promise { const api = await loadFreighterApi(); - const networkName = useWalletStore.getState().network?.name ?? "TESTNET"; - const networkPassphrase = NETWORK_PASSPHRASES[networkName] ?? NETWORK_PASSPHRASES.TESTNET; + + const networkPassphrase = + NETWORK_PASSPHRASES[network?.name ?? "TESTNET"] || NETWORK_PASSPHRASES.TESTNET; const result = await api.signTransaction(unsignedTxXdr, { networkPassphrase, @@ -243,15 +241,15 @@ export function WalletProvider({ children }: WalletProviderProps) { return result; } - if (result.error) { + if (result && typeof result === "object" && "error" in result) { throw new Error(normalizeWalletError(result.error)); } - if (result.signedTransaction) { - return result.signedTransaction; - } + throw new Error("Failed to sign transaction or operation cancelled."); + } - throw new Error("Signing failed: No signed transaction returned."); + function disconnectWallet() { + disconnect(); } async function refreshWallet() { @@ -323,8 +321,8 @@ export function WalletProvider({ children }: WalletProviderProps) { connectWallet, disconnectWallet, refreshWallet, - isFreighterAvailable, signTransaction, + isFreighterAvailable, }} > {children} diff --git a/frontend/src/app/hooks/useSSE.ts b/frontend/src/app/hooks/useSSE.ts index 0291a1ee..08cd480c 100644 --- a/frontend/src/app/hooks/useSSE.ts +++ b/frontend/src/app/hooks/useSSE.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; -export type SSEStatus = "connecting" | "connected" | "disconnected"; +export type SSEStatus = "connecting" | "connected" | "disconnected" | "fallback"; interface UseSSEOptions { /** Full URL of the SSE endpoint. Pass null/undefined to disable. */ @@ -13,25 +13,34 @@ interface UseSSEOptions { onOpen?: () => void; /** Called when the connection closes with an error. */ onError?: (event: Event) => void; + /** Optional polling fallback function called when SSE is unavailable or fails. */ + onPoll?: () => void; + /** Polling interval in ms. Defaults to 30s. */ + pollingInterval?: number; } /** - * Generic SSE hook with exponential backoff reconnection. + * Generic SSE hook with exponential backoff reconnection and optional polling fallback. * * Connects to `url` and calls `onMessage` with each parsed JSON payload. * Automatically reconnects on error, backing off up to 30 s. * Returns the current connection status for UI indicators. + * + * If `onPoll` is provided, it will be called every `pollingInterval` when the SSE stream is down. */ export function useSSE({ url, onMessage, onOpen, onError, + onPoll, + pollingInterval = 30_000, }: UseSSEOptions): SSEStatus { const [status, setStatus] = useState("connecting"); const retryDelay = useRef(1_000); const esRef = useRef(null); const timeoutRef = useRef | null>(null); + const pollIntervalRef = useRef | null>(null); // Keep callback refs stable so the effect doesn't need to re-run when they // change, which would needlessly restart the connection. @@ -41,14 +50,40 @@ export function useSSE({ onOpenRef.current = onOpen; const onErrorRef = useRef(onError); onErrorRef.current = onError; + const onPollRef = useRef(onPoll); + onPollRef.current = onPoll; useEffect(() => { if (!url) return; let cancelled = false; + const startPolling = () => { + if (!onPollRef.current || pollIntervalRef.current) return; + // Immediate poll to refresh data as soon as fallback starts + onPollRef.current(); + pollIntervalRef.current = setInterval(() => { + onPollRef.current?.(); + }, pollingInterval); + }; + + const stopPolling = () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }; + function connect() { if (cancelled) return; + + // Fallback if browser doesn't support EventSource + if (typeof window !== "undefined" && !window.EventSource) { + setStatus("fallback"); + startPolling(); + return; + } + setStatus("connecting"); const es = new EventSource(url as string, { withCredentials: true }); @@ -57,6 +92,7 @@ export function useSSE({ es.onopen = () => { retryDelay.current = 1_000; setStatus("connected"); + stopPolling(); // Stop fallback polling once stream is active onOpenRef.current?.(); }; @@ -72,7 +108,14 @@ export function useSSE({ es.onerror = (event) => { es.close(); esRef.current = null; - setStatus("disconnected"); + + if (onPollRef.current) { + setStatus("fallback"); + if (!cancelled) startPolling(); + } else { + setStatus("disconnected"); + } + onErrorRef.current?.(event); if (!cancelled) { @@ -89,9 +132,10 @@ export function useSSE({ cancelled = true; esRef.current?.close(); esRef.current = null; + stopPolling(); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; - }, [url]); + }, [url, pollingInterval]); return status; }