Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions libs/ui-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,34 @@ import { BridgeHistory } from '@bridgewise/ui-components';
<BridgeHistory account={account} status="confirmed" />;
```

### 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 (
<div>
{loading && <span>Connecting...</span>}
<div>Status: {status || 'unknown'}</div>
{error && <div className="error">{error.message}</div>}
{lastUpdate && <small>Updated {lastUpdate.toISOString()}</small>}
</div>
);
}
```

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.
Expand Down
133 changes: 133 additions & 0 deletions libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
202 changes: 202 additions & 0 deletions libs/ui-components/src/hooks/useTransactionStatus.ts
Original file line number Diff line number Diff line change
@@ -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<BridgeTransactionStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);

const eventSourceRef = useRef<EventSource | null>(null);
const pollingRef = useRef<number | null>(null);
const historyStorageRef = useRef<TransactionHistoryStorage | null>(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<BridgeTransaction> = {
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,
};
}
3 changes: 3 additions & 0 deletions libs/ui-components/src/types/testing-library-react-hooks.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// declare module to satisfy TypeScript during tests

declare module '@testing-library/react-hooks';
Loading