Skip to content

Commit b19a73d

Browse files
feat(frontend): add top-level error boundary to StellarChatInterface
Refactor StellarChatInterface into an inner StellarChatInterfaceContent component wrapped by ErrorBoundary at the top level. This ensures any render error in the full interface tree is caught and displays a user- friendly fallback UI with a reload button. - Rename StellarChatInterface → StellarChatInterfaceContent (inner) - Add new default export StellarChatInterface wrapping with ErrorBoundary - Remove stray comment - Add unit test verifying error boundary fallback renders correctly Closes #666
1 parent 81c814f commit b19a73d

2 files changed

Lines changed: 185 additions & 10 deletions

File tree

dex_with_fiat_frontend/src/components/StellarChatInterface.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ type HealthStatus = 'checking' | 'ok' | 'degraded';
6161

6262
const HEALTH_POLL_INTERVAL_MS = 60_000;
6363

64-
export default function StellarChatInterface() {
64+
function StellarChatInterfaceContent() {
6565
const { t } = useTranslation();
6666
const {
6767
connection,
@@ -430,7 +430,6 @@ export default function StellarChatInterface() {
430430
},
431431
}[healthStatus];
432432
// ───────────────────────────────────────────────────────────────────────────
433-
//fhj
434433
const withdrawalQueueTone =
435434
withdrawalQueueDepth === null
436435
? isDarkMode
@@ -443,13 +442,7 @@ export default function StellarChatInterface() {
443442
: 'bg-red-500/15 text-red-400';
444443

445444
return (
446-
<ErrorBoundary
447-
isDarkMode={isDarkMode}
448-
title={t('common.error_boundary_title') || 'Interface Error'}
449-
message={t('common.error_boundary_message') || 'The interface encountered an unexpected error.'}
450-
onRetry={() => window.location.reload()}
451-
>
452-
<div className="theme-app flex h-screen w-screen overflow-hidden transition-colors duration-300">
445+
<div className="theme-app flex h-screen w-screen overflow-hidden transition-colors duration-300">
453446
{/* Desktop sidebar - only rendered on lg+ viewports or when toggled */}
454447
{!isMobile && (
455448
<div
@@ -1028,6 +1021,21 @@ export default function StellarChatInterface() {
10281021
</div>
10291022
)}
10301023
</div>
1024+
);
1025+
}
1026+
1027+
/** Top-level error boundary: wraps the full interface tree so render errors are contained. */
1028+
export default function StellarChatInterface() {
1029+
const { isDarkMode } = useTheme();
1030+
const { t } = useTranslation();
1031+
return (
1032+
<ErrorBoundary
1033+
isDarkMode={isDarkMode}
1034+
title={t('common.error_boundary_title') || 'Interface Error'}
1035+
message={t('common.error_boundary_message') || 'The interface encountered an unexpected error.'}
1036+
onRetry={() => window.location.reload()}
1037+
>
1038+
<StellarChatInterfaceContent />
10311039
</ErrorBoundary>
10321040
);
1033-
}
1041+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { render, screen, cleanup } from '@testing-library/react';
3+
import StellarChatInterface from '@/components/StellarChatInterface';
4+
5+
vi.mock('@/contexts/ThemeContext', () => ({
6+
useTheme: () => ({ isDarkMode: false, toggleDarkMode: vi.fn() }),
7+
}));
8+
9+
vi.mock('@/contexts/TranslationContext', () => ({
10+
useTranslation: () => ({
11+
t: (key: string) => key,
12+
}),
13+
}));
14+
15+
vi.mock('@/contexts/UserPreferencesContext', () => ({
16+
useUserPreferences: () => ({ fiatCurrency: 'NGN' }),
17+
}));
18+
19+
vi.mock('@/contexts/StellarWalletContext', () => ({
20+
EXPECTED_NETWORK: 'Test',
21+
useStellarWallet: () => ({
22+
connection: {
23+
address: '',
24+
publicKey: '',
25+
isConnected: false,
26+
network: 'TEST',
27+
},
28+
accounts: [] as { address: string; name?: string }[],
29+
selectedAccountIndex: 0,
30+
selectAccount: vi.fn(),
31+
connect: vi.fn(),
32+
disconnect: vi.fn(),
33+
signTx: vi.fn(),
34+
isFreighterInstalled: true,
35+
isLoading: false,
36+
error: null,
37+
sessionExpired: false,
38+
clearSessionExpired: vi.fn(),
39+
mockConnect: vi.fn(),
40+
isNetworkMismatch: false,
41+
}),
42+
}));
43+
44+
vi.mock('@/hooks/useChat', () => ({
45+
default: () => ({
46+
messages: [] as { id: string; role: string; content: string; timestamp: Date }[],
47+
isLoading: false,
48+
sendMessage: vi.fn(),
49+
cancelPendingRequest: vi.fn(),
50+
clearChat: vi.fn(),
51+
loadChatSession: vi.fn(),
52+
currentSessionId: null as string | null,
53+
setTransactionReadyCallback: vi.fn(),
54+
setIsAdmin: vi.fn(),
55+
}),
56+
}));
57+
58+
vi.mock('@/hooks/useBridgeStats', () => ({
59+
default: () => ({
60+
balance: null,
61+
limit: null,
62+
totalDeposited: null,
63+
loading: false,
64+
error: null,
65+
refetchStats: vi.fn(),
66+
refresh: vi.fn(),
67+
}),
68+
}));
69+
70+
vi.mock('@/hooks/useTxHistory', () => ({
71+
useTxHistory: () => ({
72+
entries: [],
73+
clearEntries: vi.fn(),
74+
updateEntry: vi.fn(),
75+
}),
76+
}));
77+
78+
vi.mock('@/hooks/useChatHistory', () => ({
79+
useChatHistory: () => ({
80+
sessions: [],
81+
}),
82+
}));
83+
84+
vi.mock('@/hooks/useSplitView', () => ({
85+
useSplitView: () => ({
86+
state: {
87+
isOpen: false,
88+
leftSessionId: null,
89+
rightSessionId: null,
90+
selectedMessageId: null,
91+
},
92+
open: vi.fn(),
93+
close: vi.fn(),
94+
setLeftSession: vi.fn(),
95+
setRightSession: vi.fn(),
96+
swapSessions: vi.fn(),
97+
selectMessage: vi.fn(),
98+
leftSession: null,
99+
rightSession: null,
100+
}),
101+
}));
102+
103+
vi.mock('@/hooks/usePaystackWebhookStatus', () => ({
104+
usePaystackWebhookStatus: () => undefined,
105+
}));
106+
107+
vi.mock('@/lib/networkQueue', () => ({
108+
getQueuedReadRequestsCount: () => 0,
109+
subscribeToQueue: () => () => undefined,
110+
processQueue: vi.fn(),
111+
}));
112+
113+
vi.mock('@/lib/stellarContract', () => ({
114+
getAdmin: vi.fn().mockResolvedValue(null),
115+
getWithdrawalQueueDepth: vi.fn().mockResolvedValue(0),
116+
stroopsToDisplay: (n: string | number) => String(n),
117+
}));
118+
119+
vi.mock('@/components/ChatHistorySidebar', () => ({ default: () => null }));
120+
vi.mock('@/components/ChatInput', () => ({ default: () => null }));
121+
vi.mock('@/components/ChatMessages', () => ({ default: () => null }));
122+
vi.mock('@/components/StellarFiatModal', () => ({ default: () => null }));
123+
vi.mock('@/components/BankDetailsModal', () => ({ default: () => null }));
124+
vi.mock('@/components/UserSettings', () => ({ default: () => null }));
125+
vi.mock('@/components/WalletConnectionTimeline', () => ({ default: () => null }));
126+
vi.mock('@/components/ReceiptDrawerWrapper', () => ({ default: () => null }));
127+
vi.mock('@/components/SplitViewComparison', () => ({ default: () => null }));
128+
vi.mock('@/components/ChatSearchPanel', () => ({ default: () => null }));
129+
vi.mock('@/components/ui/skeleton/SkeletonChat', () => ({ default: () => null }));
130+
vi.mock('@/components/ui/skeleton/SkeletonSidebar', () => ({ default: () => null }));
131+
vi.mock('@/components/NotificationsCenter', () => ({
132+
default: function NotificationsBoom() {
133+
throw new Error('notifications test throw');
134+
},
135+
}));
136+
137+
describe('StellarChatInterface', () => {
138+
beforeEach(() => {
139+
global.fetch = vi.fn().mockResolvedValue({
140+
ok: true,
141+
json: async () => ({}),
142+
} as Response);
143+
Object.defineProperty(window, 'innerWidth', {
144+
writable: true,
145+
configurable: true,
146+
value: 1200,
147+
});
148+
});
149+
150+
afterEach(() => {
151+
cleanup();
152+
vi.clearAllMocks();
153+
});
154+
155+
it('shows the top-level interface error UI when a header child throws', () => {
156+
const consoleErrorSpy = vi
157+
.spyOn(console, 'error')
158+
.mockImplementation(() => undefined);
159+
160+
render(<StellarChatInterface />);
161+
162+
expect(screen.getByText('common.error_boundary_title')).toBeTruthy();
163+
expect(screen.getByText('common.error_boundary_message')).toBeTruthy();
164+
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy();
165+
expect(consoleErrorSpy).toHaveBeenCalled();
166+
});
167+
});

0 commit comments

Comments
 (0)