From 2187b37d31a3bfec77c65104b2d8a9e348805ec9 Mon Sep 17 00:00:00 2001 From: OthmanImam Date: Thu, 26 Feb 2026 12:47:11 +0100 Subject: [PATCH] feat(ui-components): add useTransactionStatus hook with SSE/polling, tests, and docs --- libs/ui-components/README.md | 28 +++ .../__tests__/useTransactionStatus.spec.ts | 133 ++++++++++++ .../src/hooks/useTransactionStatus.ts | 202 ++++++++++++++++++ .../types/testing-library-react-hooks.d.ts | 3 + 4 files changed, 366 insertions(+) create mode 100644 libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts create mode 100644 libs/ui-components/src/hooks/useTransactionStatus.ts create mode 100644 libs/ui-components/src/types/testing-library-react-hooks.d.ts diff --git a/libs/ui-components/README.md b/libs/ui-components/README.md index c29136a..980cecf 100644 --- a/libs/ui-components/README.md +++ b/libs/ui-components/README.md @@ -157,6 +157,34 @@ import { BridgeHistory } from '@bridgewise/ui-components'; ; ``` +### Real-Time Transaction Status + +In addition to viewing historical records, you can subscribe to live updates for a specific transaction. This is useful when you need to show a spinner, send notifications or update other parts of your UI as the bridge work progresses (e.g. refresh quotes, run slippage checks). + +```tsx +import { useTransactionStatus } from '@bridgewise/ui-components'; + +function TransactionTracker({ txId }: { txId: string }) { + const { status, loading, error, lastUpdate } = useTransactionStatus(txId, { + pollingIntervalMs: 3000, + notifications: true, // browser notification when status changes + onStatusChange: (s) => console.log('status:', s), + }); + + return ( +
+ {loading && Connecting...} +
Status: {status || 'unknown'}
+ {error &&
{error.message}
} + {lastUpdate && Updated {lastUpdate.toISOString()}} +
+ ); +} +``` + +Support also exists for storing status updates in the same history used by `useTransactionHistory` via the `historyConfig`/`account` options. + +### Storage configuration ### Storage configuration By default, history is persisted in browser local storage. diff --git a/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts b/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts new file mode 100644 index 0000000..819601e --- /dev/null +++ b/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts @@ -0,0 +1,133 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useTransactionStatus, UseTransactionStatusOptions } from '../useTransactionStatus'; +import type { BridgeTransactionStatus } from '../../transaction-history/types'; + +describe('useTransactionStatus', () => { + let originalEventSource: any; + let eventSources: any[]; + + beforeEach(() => { + // capture the original so we can restore later + originalEventSource = (global as any).EventSource; + eventSources = []; + + // simple fake EventSource implementation + class FakeEventSource { + onmessage: ((evt: any) => void) | null = null; + onerror: ((evt: any) => void) | null = null; + constructor(public url: string) { + eventSources.push(this); + } + close() { + // nothing + } + } + + (global as any).EventSource = FakeEventSource; + + // default fetch mock + (global as any).fetch = jest.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'pending' }) }), + ); + }); + + afterEach(() => { + (global as any).EventSource = originalEventSource; + jest.resetAllMocks(); + jest.useRealTimers(); + }); + + it('returns null status initially and does not crash on server', () => { + const { result } = renderHook(() => useTransactionStatus(null)); + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('is SSR-safe when window is undefined', () => { + const prev = (global as any).window; + // @ts-ignore remove window + delete (global as any).window; + const { result } = renderHook(() => useTransactionStatus('tx', {})); + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + (global as any).window = prev; + }); + + it('subscribes to SSE and updates status on message', () => { + const { result } = renderHook(() => useTransactionStatus('tx123')); + + expect(eventSources.length).toBe(1); + act(() => { + eventSources[0].onmessage({ data: JSON.stringify({ status: 'confirmed' }) }); + }); + expect(result.current.status).toBe('confirmed'); + expect(result.current.loading).toBe(false); + }); + + it('calls onStatusChange callback and notifications handler', () => { + const changes: BridgeTransactionStatus[] = []; + const notifs: BridgeTransactionStatus[] = []; + const options: UseTransactionStatusOptions = { + onStatusChange: (s) => changes.push(s), + notifications: (s) => notifs.push(s), + }; + renderHook(() => useTransactionStatus('txABC', options)); + act(() => { + eventSources[0].onmessage({ data: JSON.stringify({ status: 'failed' }) }); + }); + expect(changes).toEqual(['failed']); + expect(notifs).toEqual(['failed']); + }); + + it('maps unknown statuses to pending', () => { + const { result } = renderHook(() => useTransactionStatus('txUnknown')); + act(() => { + eventSources[0].onmessage({ data: JSON.stringify({ status: 'in-progress' }) }); + }); + expect(result.current.status).toBe('pending'); + }); + + it('updates history storage when status changes', () => { + const backend = { + saveTransaction: jest.fn(() => Promise.resolve()), + getTransactionsByAccount: jest.fn(() => Promise.resolve([])), + } as any; + const { result } = renderHook(() => + useTransactionStatus('txHistory', { + historyConfig: { backend }, + account: 'acct1', + }), + ); + + act(() => { + eventSources[0].onmessage({ data: JSON.stringify({ status: 'confirmed' }) }); + }); + + // storage should have been called with at least the status and account + expect(backend.saveTransaction).toHaveBeenCalled(); + }); + + it('falls back to polling when SSE errors', async () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useTransactionStatus('txpoll', { pollingIntervalMs: 100 })); + // trigger error to force polling + act(() => { + eventSources[0].onerror && eventSources[0].onerror(new Event('error')); + }); + + // verify fetch gets called immediately and eventually + expect((global as any).fetch).toHaveBeenCalledWith('/transactions/txpoll/poll'); + // make fetch return confirmed status next time + (global as any).fetch.mockImplementationOnce(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'confirmed' }) }), + ); + // advance timers to run polling interval + await act(async () => { + jest.advanceTimersByTime(150); + // wait for promise resolution + await Promise.resolve(); + }); + + expect(result.current.status).toBe('confirmed'); + }); +}); diff --git a/libs/ui-components/src/hooks/useTransactionStatus.ts b/libs/ui-components/src/hooks/useTransactionStatus.ts new file mode 100644 index 0000000..453b312 --- /dev/null +++ b/libs/ui-components/src/hooks/useTransactionStatus.ts @@ -0,0 +1,202 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import type { + BridgeTransactionStatus, +} from '../transaction-history/types'; +import { TransactionHistoryStorage } from '../transaction-history/storage'; +import type { TransactionHistoryConfig, BridgeTransaction } from '../transaction-history/types'; + +export interface UseTransactionStatusOptions { + /** Interval to poll when SSE is unavailable or fails */ + pollingIntervalMs?: number; + /** Callback invoked whenever status changes */ + onStatusChange?: (status: BridgeTransactionStatus) => void; + /** Enable desktop/browser notification or custom handler */ + notifications?: boolean | ((status: BridgeTransactionStatus) => void); + /** Optional configuration used to persist status to history storage */ + historyConfig?: TransactionHistoryConfig; + /** Account for history persistence (required when historyConfig is provided) */ + account?: string; +} + +export interface UseTransactionStatusReturn { + status: BridgeTransactionStatus | null; + loading: boolean; + error?: Error; + lastUpdate: Date | null; +} + +const DEFAULT_POLL_INTERVAL = 5000; + +/** + * Hook for tracking the real-time status of a transaction by its ID/Hash. + * It attempts to open an EventSource connection to `/transactions/:id/events` + * and falls back to polling `/transactions/:id/poll` if SSE fails or is not available. + * + * The hook is SSR-safe (will do nothing during server rendering). + */ +export function useTransactionStatus( + txId: string | null | undefined, + options: UseTransactionStatusOptions = {}, +): UseTransactionStatusReturn { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const [lastUpdate, setLastUpdate] = useState(null); + + const eventSourceRef = useRef(null); + const pollingRef = useRef(null); + const historyStorageRef = useRef(null); + + // lazily initialise history storage if needed + useEffect(() => { + if (options.historyConfig) { + historyStorageRef.current = new TransactionHistoryStorage(options.historyConfig); + } + }, [options.historyConfig]); + + useEffect(() => { + if (!txId || typeof window === 'undefined') { + return; + } + + let active = true; + setLoading(true); + + const notify = (newStatus: BridgeTransactionStatus) => { + options.onStatusChange?.(newStatus); + if (options.notifications) { + if (typeof options.notifications === 'function') { + options.notifications(newStatus); + } else if (typeof Notification !== 'undefined') { + if (Notification.permission === 'granted') { + new Notification(`Transaction status: ${newStatus}`); + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + new Notification(`Transaction status: ${newStatus}`); + } + }); + } + } + } + // update history if config supplied + if (historyStorageRef.current && options.account) { + const record: Partial = { + txHash: txId, + status: newStatus, + timestamp: new Date(), + account: options.account, + // other fields unknown; history storage will fill defaults + } as any; + historyStorageRef.current.upsertTransaction(record as BridgeTransaction).catch(() => { + /* swallow */ + }); + } + }; + + const handleMessage = (e: MessageEvent) => { + try { + const data = JSON.parse(e.data); + if (data && data.status) { + // guard against unexpected status values; default to pending + const candidate = data.status as string; + const allowed: BridgeTransactionStatus[] = ['pending', 'confirmed', 'failed']; + const newStatus: BridgeTransactionStatus = allowed.includes(candidate as any) + ? (candidate as BridgeTransactionStatus) + : 'pending'; + + setStatus(newStatus); + setLastUpdate(new Date()); + setLoading(false); + notify(newStatus); + if (newStatus !== 'pending') { + cleanup(); + } + } + } catch (err) { + // ignore malformed payload + } + }; + + const handleError = () => { + // SSE connection failed; fall back to polling + cleanupEventSource(); + startPolling(); + }; + + const cleanupEventSource = () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + + const startPolling = () => { + const poll = async () => { + try { + const resp = await fetch(`/transactions/${txId}/poll`); + if (!resp.ok) throw new Error('poll failed'); + const data = await resp.json(); + if (data && data.status) { + const candidate = data.status as string; + const allowed: BridgeTransactionStatus[] = ['pending', 'confirmed', 'failed']; + const newStatus: BridgeTransactionStatus = allowed.includes(candidate as any) + ? (candidate as BridgeTransactionStatus) + : 'pending'; + + setStatus(newStatus); + setLastUpdate(new Date()); + setLoading(false); + notify(newStatus); + if (newStatus !== 'pending') { + stopPolling(); + } + } + } catch (err) { + setError(err as Error); + } + }; + poll(); + const interval = options.pollingIntervalMs ?? DEFAULT_POLL_INTERVAL; + pollingRef.current = window.setInterval(poll, interval); + }; + + const stopPolling = () => { + if (pollingRef.current !== null) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }; + + const cleanup = () => { + cleanupEventSource(); + stopPolling(); + setLoading(false); + }; + + // try SSE first + try { + const es = new EventSource(`/transactions/${txId}/events`); + eventSourceRef.current = es; + es.onmessage = handleMessage; + es.onerror = handleError; + } catch (err) { + // if construction throws, fallback to polling + startPolling(); + } + + return () => { + active = false; + cleanup(); + }; + }, [txId, options.onStatusChange, options.notifications, options.pollingIntervalMs, options.account]); + + return { + status, + loading, + error, + lastUpdate, + }; +} diff --git a/libs/ui-components/src/types/testing-library-react-hooks.d.ts b/libs/ui-components/src/types/testing-library-react-hooks.d.ts new file mode 100644 index 0000000..5eccf2c --- /dev/null +++ b/libs/ui-components/src/types/testing-library-react-hooks.d.ts @@ -0,0 +1,3 @@ +// declare module to satisfy TypeScript during tests + +declare module '@testing-library/react-hooks';