Couldn't load transaction history
diff --git a/components/ThemeToggle.test.tsx b/components/ThemeToggle.test.tsx
new file mode 100644
index 0000000..430b0b5
--- /dev/null
+++ b/components/ThemeToggle.test.tsx
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { getNextTheme } from './ThemeToggle';
+
+describe('ThemeToggle theme cycling', () => {
+ it('cycles explicit light mode to dark mode', () => {
+ expect(getNextTheme('light')).toBe('dark');
+ });
+
+ it('cycles explicit dark mode back to system mode', () => {
+ expect(getNextTheme('dark')).toBe('system');
+ });
+
+ it('cycles system or missing theme to light mode', () => {
+ expect(getNextTheme('system')).toBe('light');
+ expect(getNextTheme(undefined)).toBe('light');
+ });
+});
diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx
index 7df7c0a..0988e11 100644
--- a/components/ThemeToggle.tsx
+++ b/components/ThemeToggle.tsx
@@ -1,9 +1,17 @@
'use client';
import { useTheme } from 'next-themes';
-import { Sun, Moon } from 'lucide-react';
+import { Sun, Moon, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
+type ThemeChoice = 'light' | 'dark' | 'system';
+
+export function getNextTheme(theme?: string): ThemeChoice {
+ if (theme === 'light') return 'dark';
+ if (theme === 'dark') return 'system';
+ return 'light';
+}
+
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -28,14 +36,28 @@ export function ThemeToggle() {
}
const isDark = theme === 'dark' || (theme === 'system' && systemPrefersDark);
+ const nextTheme = getNextTheme(theme);
+ const label =
+ nextTheme === 'system'
+ ? 'Match system theme'
+ : nextTheme === 'dark'
+ ? 'Switch to dark mode'
+ : 'Switch to light mode';
return (
setTheme(isDark ? 'light' : 'dark')}
+ onClick={() => setTheme(nextTheme)}
className="inline-flex items-center justify-center w-9 h-9 rounded text-gray-500 hover:text-black hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800 transition-colors"
- aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
+ aria-label={label}
+ title={label}
>
- {isDark ? : }
+ {theme === 'system' ? (
+
+ ) : isDark ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/components/ui/NotificationCenter.test.tsx b/components/ui/NotificationCenter.test.tsx
deleted file mode 100644
index 00cf0e8..0000000
--- a/components/ui/NotificationCenter.test.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import React from 'react';
-import { act } from 'react';
-import { createRoot } from 'react-dom/client';
-import { NotificationCenter } from './NotificationCenter';
-
-describe('NotificationCenter', () => {
- let container: HTMLDivElement | undefined;
- let root: ReturnType | undefined;
-
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- if (root) act(() => root?.unmount());
- container?.remove();
- root = undefined;
- container = undefined;
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- function setup() {
- container = document.createElement('div');
- document.body.appendChild(container);
- root = createRoot(container);
- act(() => root?.render( ));
- return container;
- }
-
- it('removes its event listener on unmount and bounds retained notifications', () => {
- const removeListener = vi.spyOn(window, 'removeEventListener');
- const el = setup();
-
- act(() => {
- for (let i = 0; i < 8; i++) {
- window.dispatchEvent(new CustomEvent('notification', { detail: `Notice ${i}` }));
- }
- window.dispatchEvent(new CustomEvent('notification', { detail: null }));
- });
-
- expect(el.textContent).toContain('Notice 7');
- expect(el.textContent).not.toContain('Notice 0');
-
- act(() => root?.unmount());
- expect(removeListener).toHaveBeenCalledWith('notification', expect.any(Function));
- });
-
- it('auto-dismisses a notification after the TTL to prevent infinite loading spinners', () => {
- const el = setup();
-
- act(() => {
- window.dispatchEvent(new CustomEvent('notification', { detail: 'Loading…' }));
- });
-
- // Notification should be visible immediately
- expect(el.textContent).toContain('Loading…');
-
- // After TTL (10 s), the notification must be gone — fixes #195
- act(() => {
- vi.advanceTimersByTime(10_000);
- });
-
- expect(el.textContent).not.toContain('Loading…');
- });
-
- it('clears all auto-dismiss timers on unmount to prevent memory leaks', () => {
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
- setup();
-
- act(() => {
- window.dispatchEvent(new CustomEvent('notification', { detail: 'Notice A' }));
- window.dispatchEvent(new CustomEvent('notification', { detail: 'Notice B' }));
- });
-
- act(() => root?.unmount());
-
- // clearTimeout must have been called at least once for each notification timer
- expect(clearTimeoutSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
- });
-
- it('renders nothing when there are no notifications', () => {
- const el = setup();
- expect(el.firstChild).toBeNull();
- });
-
- it('ignores notification events with non-string or empty detail', () => {
- const el = setup();
-
- act(() => {
- window.dispatchEvent(new CustomEvent('notification', { detail: null }));
- window.dispatchEvent(new CustomEvent('notification', { detail: 42 }));
- window.dispatchEvent(new CustomEvent('notification', { detail: '' }));
- });
-
- expect(el.firstChild).toBeNull();
- });
-});
diff --git a/components/ui/NotificationCenter.tsx b/components/ui/NotificationCenter.tsx
deleted file mode 100644
index 1e473c6..0000000
--- a/components/ui/NotificationCenter.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-'use client';
-
-import { useEffect, useRef, useState } from 'react';
-
-const MAX_NOTIFICATIONS = 5;
-
-/**
- * Timeout (ms) after which a notification is automatically dismissed.
- * Prevents stale loading-state notifications from hanging indefinitely
- * if the RPC provider times out and no dismissal event is dispatched.
- */
-const NOTIFICATION_TTL_MS = 10_000;
-
-export interface NotificationItem {
- id: number;
- message: string;
- /** Timestamp the notification was added — used to schedule auto-dismiss. */
- addedAt: number;
-}
-
-export function NotificationCenter() {
- const [notifications, setNotifications] = useState([]);
- const counterRef = useRef(0);
-
- useEffect(() => {
- const timers = new Map>();
-
- const dismiss = (id: number) => {
- timers.delete(id);
- setNotifications((prev) => prev.filter((n) => n.id !== id));
- };
-
- const handleNotification = (event: Event) => {
- const detail = (event as CustomEvent).detail;
- if (typeof detail === 'string' && detail) {
- const id = ++counterRef.current;
- const item: NotificationItem = { id, message: detail, addedAt: Date.now() };
-
- setNotifications((prev) => {
- const next = [...prev, item];
- // If we're over the cap, cancel timers for the notifications being evicted
- const evicted = next.slice(0, next.length - MAX_NOTIFICATIONS);
- evicted.forEach((n) => {
- const t = timers.get(n.id);
- if (t !== undefined) clearTimeout(t);
- timers.delete(n.id);
- });
- return next.slice(-MAX_NOTIFICATIONS);
- });
-
- // Auto-dismiss after TTL so a hung RPC call never leaves a spinner on screen.
- timers.set(id, setTimeout(() => dismiss(id), NOTIFICATION_TTL_MS));
- }
- };
-
- window.addEventListener('notification', handleNotification);
-
- return () => {
- window.removeEventListener('notification', handleNotification);
- // Clear all pending auto-dismiss timers on unmount.
- timers.forEach((t) => clearTimeout(t));
- timers.clear();
- };
- }, []);
-
- if (notifications.length === 0) return null;
-
- return (
-
- {notifications.map((note) => (
-
- {note.message}
-
- ))}
-
- );
-}
diff --git a/lib/indexer.test.ts b/lib/indexer.test.ts
index 8fa3bee..4daadaf 100644
--- a/lib/indexer.test.ts
+++ b/lib/indexer.test.ts
@@ -1,102 +1,55 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import {
- fetchStreamsFromIndexer,
- fetchTransactionHistory,
- fetchTransactionHistoryWithTimeout,
-} from './indexer';
-import * as factory from './factory';
-import * as stream from './stream';
-
-vi.mock('./factory', () => ({
- streamsBySender: vi.fn(),
- streamsByRecipient: vi.fn(),
- isMock: vi.fn(() => true),
-}));
+import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./stream', () => ({
getStreamAddress: vi.fn(),
getStreamInfo: vi.fn(),
}));
-describe('fetchStreamsFromIndexer (#342)', () => {
- const PUBLIC_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGWEBUSAVFILHUYW5ZV';
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('fetches streams concurrently and returns indexed stream metadata', async () => {
- vi.mocked(factory.streamsBySender).mockResolvedValueOnce([1n, 2n]);
- vi.mocked(stream.getStreamAddress).mockImplementation(async (_source, id) => `ADDR_${id}`);
- vi.mocked(stream.getStreamInfo).mockImplementation(async (_source, addr) => ({
- sender: PUBLIC_KEY,
- recipient: 'GRECIPIENT',
- token: 'XLM',
- ratePerSecond: 100n,
- startTime: 1000,
- endTime: 2000,
- withdrawn: 0n,
- paused: false,
- pausedAt: 0,
- clawbackEnabled: false,
- cancelled: false,
- }));
+const { mockIsMock } = vi.hoisted(() => ({
+ mockIsMock: vi.fn(),
+}));
- const result = await fetchStreamsFromIndexer(PUBLIC_KEY, 'sender', { maxConcurrency: 2 });
+vi.mock('./factory', () => ({
+ streamsBySender: vi.fn(),
+ streamsByRecipient: vi.fn(),
+ isMock: mockIsMock,
+}));
- expect(result.length).toBe(2);
- expect(result.streams.length).toBe(2);
- expect(result.failedIds).toEqual([]);
- expect(result.errors).toEqual([]);
- expect(result[0]!.id).toBe('1');
- expect(result[0]!.address).toBe('ADDR_1');
- expect(result[1]!.id).toBe('2');
- expect(result[1]!.address).toBe('ADDR_2');
+describe('fetchTransactionHistory indexer availability', () => {
+ beforeEach(() => {
+ mockIsMock.mockReset();
});
- it('surfaces partial failure and invokes onPartialFailure callback when individual stream RPCs fail', async () => {
- vi.mocked(factory.streamsBySender).mockResolvedValueOnce([1n, 2n, 3n]);
- vi.mocked(stream.getStreamAddress).mockImplementation(async (_source, id) => {
- if (id === 2n) throw new Error('RPC connection dropped');
- return `ADDR_${id}`;
- });
- vi.mocked(stream.getStreamInfo).mockImplementation(async (_source, addr) => {
- if (addr === 'ADDR_3') throw new Error('Contract simulation failed');
- return {
- sender: PUBLIC_KEY,
- recipient: 'GRECIPIENT',
- token: 'XLM',
- ratePerSecond: 100n,
- startTime: 1000,
- endTime: 2000,
- withdrawn: 0n,
- paused: false,
- pausedAt: 0,
- clawbackEnabled: false,
- cancelled: false,
- };
- });
-
- const onPartialFailure = vi.fn();
- const result = await fetchStreamsFromIndexer(PUBLIC_KEY, 'sender', { onPartialFailure });
-
- expect(result.length).toBe(1);
- expect(result[0]!.id).toBe('1');
- expect(result.failedIds).toEqual(['2', '3']);
- expect(result.errors.length).toBe(2);
- expect(result.errors[0]!.id).toBe('2');
- expect(result.errors[0]!.error.message).toMatch(/RPC connection dropped/);
- expect(result.errors[1]!.id).toBe('3');
- expect(result.errors[1]!.error.message).toMatch(/Contract simulation failed/);
- expect(onPartialFailure).toHaveBeenCalledWith(['2', '3'], result.errors);
+ it('uses a typed error when transaction history is not configured', async () => {
+ expect.assertions(2);
+ mockIsMock.mockReturnValue(false);
+ const {
+ fetchTransactionHistory,
+ IndexerNotConfiguredError,
+ isIndexerNotConfiguredError,
+ } = await import('./indexer.js');
+
+ await expect(fetchTransactionHistory('GTEST')).rejects.toBeInstanceOf(
+ IndexerNotConfiguredError,
+ );
+
+ try {
+ await fetchTransactionHistory('GTEST');
+ } catch (error) {
+ expect(isIndexerNotConfiguredError(error)).toBe(true);
+ }
});
- it('respects AbortSignal cancellation', async () => {
- const controller = new AbortController();
- controller.abort();
-
- await expect(
- fetchStreamsFromIndexer(PUBLIC_KEY, 'sender', { signal: controller.signal }),
- ).rejects.toThrow(/Aborted/);
+ it('preserves the typed not-configured error through the timeout wrapper', async () => {
+ expect.assertions(1);
+ mockIsMock.mockReturnValue(false);
+ const { fetchTransactionHistoryWithTimeout, isIndexerNotConfiguredError } =
+ await import('./indexer.js');
+
+ try {
+ await fetchTransactionHistoryWithTimeout('GTEST');
+ } catch (error) {
+ expect(isIndexerNotConfiguredError(error)).toBe(true);
+ }
});
});
diff --git a/lib/indexer.ts b/lib/indexer.ts
index bee4cb5..f92d8f5 100644
--- a/lib/indexer.ts
+++ b/lib/indexer.ts
@@ -27,6 +27,17 @@ const DEMO_TXS: TransactionRow[] = [
/** How long to wait for the subgraph before treating it as unavailable. */
const SUBGRAPH_TIMEOUT_MS = 10_000;
+export class IndexerNotConfiguredError extends Error {
+ constructor() {
+ super('Transaction history is unavailable — the subgraph/indexer is not configured yet.');
+ this.name = 'IndexerNotConfiguredError';
+ }
+}
+
+export function isIndexerNotConfiguredError(error: unknown): error is IndexerNotConfiguredError {
+ return error instanceof IndexerNotConfiguredError;
+}
+
/**
* Fetch transaction history from the subgraph/indexer.
*
@@ -76,11 +87,7 @@ export async function fetchTransactionHistory(
if (mock) {
resolve(publicKey ? [] : DEMO_TXS);
} else {
- reject(
- new Error(
- 'Transaction history is unavailable — the subgraph/indexer is not configured yet.',
- ),
- );
+ reject(new IndexerNotConfiguredError());
}
});
}
diff --git a/lib/useServiceWorker.transactions.test.ts b/lib/useServiceWorker.transactions.test.ts
new file mode 100644
index 0000000..18cc0cb
--- /dev/null
+++ b/lib/useServiceWorker.transactions.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./queryClient', () => ({
+ queryClient: { invalidateQueries: vi.fn() },
+ refreshStreamData: vi.fn(() => Promise.resolve()),
+}));
+
+vi.mock('react-hot-toast', () => {
+ const toast = vi.fn();
+ return {
+ default: Object.assign(toast, {
+ loading: vi.fn(),
+ success: vi.fn(),
+ error: vi.fn(),
+ }),
+ };
+});
+
+describe('service worker reload safety', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('detects signing, broadcasting, and confirming transactions as in-flight', async () => {
+ const { useTransactionStore } = await import('./store.js');
+ const { hasInFlightTransactions } = await import('./useServiceWorker.js');
+
+ useTransactionStore.getState().addTransaction('tx-signing', 'Signing');
+ expect(hasInFlightTransactions()).toBe(true);
+
+ useTransactionStore.getState().updateStatus('tx-signing', 'broadcasting');
+ expect(hasInFlightTransactions()).toBe(true);
+
+ useTransactionStore.getState().updateStatus('tx-signing', 'confirming');
+ expect(hasInFlightTransactions()).toBe(true);
+ });
+
+ it('treats success and failed transactions as safe to reload', async () => {
+ const { useTransactionStore } = await import('./store.js');
+ const { hasInFlightTransactions } = await import('./useServiceWorker.js');
+
+ useTransactionStore.getState().addTransaction('tx-success', 'Success');
+ useTransactionStore.getState().updateStatus('tx-success', 'success');
+ useTransactionStore.getState().addTransaction('tx-failed', 'Failed');
+ useTransactionStore.getState().updateStatus('tx-failed', 'failed');
+
+ expect(hasInFlightTransactions()).toBe(false);
+ });
+});
diff --git a/lib/useServiceWorker.ts b/lib/useServiceWorker.ts
index 7300d17..4b3f3bf 100644
--- a/lib/useServiceWorker.ts
+++ b/lib/useServiceWorker.ts
@@ -1,18 +1,29 @@
import { useEffect } from 'react';
+import toast from 'react-hot-toast';
+import { useTransactionStore, type TransactionStatus } from './store';
// Module-level guard so a remount, React StrictMode's double-invoke, or a
// second can't kick off a duplicate registration
// (#351).
let registrationStarted = false;
+const TERMINAL_TRANSACTION_STATUSES = new Set(['success', 'failed']);
+
+export function hasInFlightTransactions(): boolean {
+ return Object.values(useTransactionStore.getState().transactions).some(
+ (tx) => !TERMINAL_TRANSACTION_STATUSES.has(tx.status),
+ );
+}
+
/**
* Register `/sw.js` once per page load and activate a new worker when one ships.
*
* Without update handling a changed `sw.js` installs but sits in `waiting`
* forever — existing users keep the old (previously "always-caching") worker
* until every tab is closed. Here, when a new worker reaches `installed` while
- * one is already controlling the page, we ask it to take over and reload once
- * it does.
+ * one is already controlling the page, we ask it to take over. If transactions
+ * are still signing/broadcasting/confirming, the reload waits until they settle
+ * so the app does not discard an in-flight operation.
*/
export function useServiceWorker() {
useEffect(() => {
@@ -26,11 +37,37 @@ export function useServiceWorker() {
registrationStarted = true;
let reloading = false;
- const onControllerChange = () => {
+ let unsubscribeFromTransactions: (() => void) | undefined;
+
+ const reloadPage = () => {
if (reloading) return;
reloading = true;
+ unsubscribeFromTransactions?.();
window.location.reload();
};
+
+ const reloadWhenSafe = () => {
+ if (!hasInFlightTransactions()) {
+ reloadPage();
+ return;
+ }
+
+ toast('Update available. The app will reload after pending transactions finish.', {
+ id: 'service-worker-update',
+ duration: 5000,
+ });
+
+ unsubscribeFromTransactions?.();
+ unsubscribeFromTransactions = useTransactionStore.subscribe(() => {
+ if (!hasInFlightTransactions()) {
+ reloadPage();
+ }
+ });
+ };
+
+ const onControllerChange = () => {
+ reloadWhenSafe();
+ };
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
const promoteWhenReady = (worker: ServiceWorker | null) => {
@@ -63,6 +100,7 @@ export function useServiceWorker() {
});
return () => {
+ unsubscribeFromTransactions?.();
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
};
}, []);