From 80a94c2ea684aee0d3c0a95916e62aef2e39c1cb Mon Sep 17 00:00:00 2001 From: am_ Samuel Date: Mon, 31 Aug 2026 02:30:16 +0100 Subject: [PATCH 1/6] test(lib): cover update and settings regressions Add regression coverage for service-worker reload safety, indexer availability errors, and the theme cycle so the assigned fixes are guarded before implementation. --- components/ThemeToggle.test.tsx | 17 +++++++ lib/indexer.test.ts | 55 +++++++++++++++++++++++ lib/useServiceWorker.transactions.test.ts | 49 ++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 components/ThemeToggle.test.tsx create mode 100644 lib/indexer.test.ts create mode 100644 lib/useServiceWorker.transactions.test.ts 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/lib/indexer.test.ts b/lib/indexer.test.ts new file mode 100644 index 0000000..4daadaf --- /dev/null +++ b/lib/indexer.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./stream', () => ({ + getStreamAddress: vi.fn(), + getStreamInfo: vi.fn(), +})); + +const { mockIsMock } = vi.hoisted(() => ({ + mockIsMock: vi.fn(), +})); + +vi.mock('./factory', () => ({ + streamsBySender: vi.fn(), + streamsByRecipient: vi.fn(), + isMock: mockIsMock, +})); + +describe('fetchTransactionHistory indexer availability', () => { + beforeEach(() => { + mockIsMock.mockReset(); + }); + + 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('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/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); + }); +}); From d1198ee9957e9356a14ee4affe168ba475a20646 Mon Sep 17 00:00:00 2001 From: am_ Samuel Date: Mon, 31 Aug 2026 02:30:26 +0100 Subject: [PATCH 2/6] fix(lib): defer service worker reloads during transactions Controller changes can arrive while a wallet operation is signing, broadcasting, or confirming. Check the transaction store before reloading and wait for active transactions to settle so update activation does not interrupt in-flight work. Closes #424 --- lib/useServiceWorker.ts | 44 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) 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); }; }, []); From 6ff6311f2a4280b5978afd3bbcad8f2b178e0d3d Mon Sep 17 00:00:00 2001 From: am_ Samuel Date: Mon, 31 Aug 2026 02:30:32 +0100 Subject: [PATCH 3/6] feat(ui): add system mode to theme toggle cycle The app defaults to following the OS theme, but the navbar toggle previously pinned users to light or dark forever. Cycle dark back to system so users can return to follow-OS mode from the visible control. Closes #426 --- components/ThemeToggle.tsx | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) 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 ( ); } From e1c4cb80bce769f33b22005acd3e8a01843de7e1 Mon Sep 17 00:00:00 2001 From: am_ Samuel Date: Mon, 31 Aug 2026 02:30:43 +0100 Subject: [PATCH 4/6] refactor(ui): remove unused notification center The app already mounts react-hot-toast and no code dispatches the custom notification event. Removing the unreferenced component and test avoids keeping a second notification system that never runs. Closes #425 --- components/ui/NotificationCenter.test.tsx | 99 ----------------------- components/ui/NotificationCenter.tsx | 85 ------------------- 2 files changed, 184 deletions(-) delete mode 100644 components/ui/NotificationCenter.test.tsx delete mode 100644 components/ui/NotificationCenter.tsx 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} -
- ))} -
- ); -} From 4154173bc24769acbb1c4141c23d5b7c358e5073 Mon Sep 17 00:00:00 2001 From: am_ Samuel Date: Mon, 31 Aug 2026 02:30:52 +0100 Subject: [PATCH 5/6] fix(transactions): show unconfigured indexer as coming soon A configured non-demo deploy currently has no transaction-history indexer, which is an expected unavailable state rather than a fetch failure. Use a typed availability error so the page can skip pointless retries and render a neutral coming-soon message while keeping real failures in the error path. Closes #427 --- app/transactions/page.tsx | 26 +++++++++++++++++++++++--- lib/indexer.ts | 17 ++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index e1f984b..589b86f 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -4,7 +4,11 @@ import { AlertCircle, RefreshCw, Info } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Card } from '@/components/ui/Card'; import { formatTimestamp, truncateAddress } from '@/lib/format'; -import { fetchTransactionHistoryWithTimeout, type TransactionRow } from '@/lib/indexer'; +import { + fetchTransactionHistoryWithTimeout, + isIndexerNotConfiguredError, + type TransactionRow, +} from '@/lib/indexer'; import { useWallet } from '@/contexts/WalletContext'; const TRANSACTIONS_QUERY_KEY = ['transactions'] as const; @@ -26,10 +30,12 @@ export default function TransactionsPage() { queryKey: [...TRANSACTIONS_QUERY_KEY, publicKey], queryFn: () => fetchTransactionHistoryWithTimeout(publicKey), staleTime: 1000 * 30, - retry: 1, + retry: (failureCount, queryError) => + !isIndexerNotConfiguredError(queryError) && failureCount < 1, }); const isDemoData = connected && txs.length > 0; + const isIndexerComingSoon = status === 'error' && isIndexerNotConfiguredError(error); return (
@@ -59,10 +65,24 @@ export default function TransactionsPage() { Loading transactions…
+ ) : isIndexerComingSoon ? ( + +
+
+
) : status === 'error' ? (
-