Skip to content

Commit dd2b23b

Browse files
authored
Merge pull request #926 from MarvyNwaokobia/fix/590-631-633-663-multi-issue
fix/feat: resolve race condition, memory leak, add skeleton loading & error boundary
2 parents 597833e + 6975128 commit dd2b23b

9 files changed

Lines changed: 306 additions & 7 deletions

File tree

dex_with_fiat_frontend/src/components/AuditTable.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,50 @@ describe('AuditTable', () => {
1212
vi.restoreAllMocks();
1313
});
1414

15+
it('renders skeleton rows while loading and hides them once data arrives', async () => {
16+
let resolveFetch!: (value: unknown) => void;
17+
const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; });
18+
19+
vi.stubGlobal('fetch', vi.fn(() => fetchPromise));
20+
21+
render(React.createElement(AuditTable));
22+
23+
// While the fetch is in-flight the table should be in aria-busy state
24+
const busyTable = await waitFor(() => screen.getByRole('table', { name: /loading audit entries/i }));
25+
expect(busyTable).toHaveAttribute('aria-busy', 'true');
26+
27+
// Skeleton cells are present (5 rows × 6 cells = 30 skeleton divs)
28+
const skeletonCells = busyTable.querySelectorAll('td');
29+
expect(skeletonCells.length).toBe(30);
30+
31+
// Resolve the fetch with real data
32+
resolveFetch({
33+
ok: true,
34+
status: 200,
35+
json: async () => ({
36+
entries: [
37+
{
38+
id: 'e1',
39+
timestamp: new Date().toISOString(),
40+
adminAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
41+
actionType: 'deposit',
42+
actionDescription: 'real-row',
43+
txHash: 'abc123',
44+
status: 'success',
45+
},
46+
],
47+
total: 1,
48+
}),
49+
});
50+
51+
await waitFor(() => {
52+
expect(screen.getByText('real-row')).toBeInTheDocument();
53+
});
54+
55+
// The skeleton table should no longer be in the DOM
56+
expect(screen.queryByRole('table', { name: /loading audit entries/i })).not.toBeInTheDocument();
57+
});
58+
1559
it('does not apply stale fetch results after a newer request (abort)', async () => {
1660
vi.stubGlobal(
1761
'fetch',

dex_with_fiat_frontend/src/components/AuditTable.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
44
import { AuditEntry } from '@/types';
55
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
66
import { useToast } from '@/hooks/useToast';
7+
import Skeleton from '@/components/ui/skeleton/Skeleton';
78

89
interface AuditTableProps {
910
onRefresh?: () => void;
@@ -330,7 +331,32 @@ export default function AuditTable({}: AuditTableProps) {
330331

331332
{/* Audit Table */}
332333
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
333-
{entries.length === 0 && !loading ? (
334+
{loading ? (
335+
<table className="w-full" aria-label="Loading audit entries" aria-busy="true">
336+
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
337+
<tr>
338+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
339+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Admin</th>
340+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Action</th>
341+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Description</th>
342+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">TX Hash</th>
343+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
344+
</tr>
345+
</thead>
346+
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
347+
{Array.from({ length: 5 }).map((_, i) => (
348+
<tr key={i} className="animate-pulse">
349+
<td className="px-6 py-4"><Skeleton className="h-4 w-36" /></td>
350+
<td className="px-6 py-4"><Skeleton className="h-4 w-24" /></td>
351+
<td className="px-6 py-4"><Skeleton className="h-4 w-20" /></td>
352+
<td className="px-6 py-4"><Skeleton className="h-4 w-48" /></td>
353+
<td className="px-6 py-4"><Skeleton className="h-4 w-20" /></td>
354+
<td className="px-6 py-4"><Skeleton className="h-6 w-16 rounded-full" /></td>
355+
</tr>
356+
))}
357+
</tbody>
358+
</table>
359+
) : entries.length === 0 ? (
334360
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
335361
<p className="text-lg mb-2">No audit entries found</p>
336362
<p className="text-sm">Try adjusting your filters or check back later</p>

dex_with_fiat_frontend/src/components/ChatHistorySidebar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import jsPDF from 'jspdf';
55
import { useChatHistory } from '@/hooks/useChatHistory';
66
import { useTxHistory } from '@/hooks/useTxHistory';
77
import { useStellarWallet } from '@/contexts/StellarWalletContext';
8+
import ErrorBoundary from '@/components/ErrorBoundary';
89
import {
910
MessageSquare,
1011
Trash2,
@@ -460,6 +461,11 @@ export default function ChatHistorySidebar({
460461
};
461462

462463
return (
464+
<ErrorBoundary
465+
title="Sidebar unavailable"
466+
message="An unexpected error occurred in the chat history panel. Your conversations are safe."
467+
retryLabel="Reload sidebar"
468+
>
463469
<div
464470
className={`theme-surface theme-border h-full flex flex-col transition-all duration-300 border-r ${
465471
isCollapsed ? 'w-20' : 'w-full'
@@ -835,5 +841,6 @@ export default function ChatHistorySidebar({
835841
</div>
836842
)}
837843
</div>
844+
</ErrorBoundary>
838845
);
839846
}

dex_with_fiat_frontend/src/components/__tests__/ChatHistorySidebar.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,68 @@ describe('ChatHistorySidebar', () => {
244244
expect(screen.queryByText('History cleared')).toBeNull();
245245
});
246246
});
247+
248+
// ── Issue #633 regression: error boundary wraps ChatHistorySidebar ─────────────
249+
describe('ChatHistorySidebar error boundary (#633)', () => {
250+
beforeEach(() => {
251+
vi.useFakeTimers();
252+
global.fetch = vi.fn().mockResolvedValue({
253+
ok: true,
254+
json: async () => ({ events: [] }),
255+
} as Response);
256+
mockPinnedSessions = [];
257+
mockUnpinnedSessions = [];
258+
mockAllSessions = [];
259+
// Suppress React's error boundary console.error during tests
260+
vi.spyOn(console, 'error').mockImplementation(() => {});
261+
});
262+
263+
afterEach(() => {
264+
cleanup();
265+
vi.restoreAllMocks();
266+
vi.useRealTimers();
267+
});
268+
269+
it('renders the fallback UI when a child throws and not the crash stack', async () => {
270+
// Force PriceTicker (rendered inside the sidebar) to throw
271+
vi.doMock('@/components/PriceTicker', () => ({
272+
default: () => { throw new Error('PriceTicker exploded'); },
273+
}));
274+
275+
// Dynamically import so the new mock is picked up
276+
const { default: ChatHistorySidebarFresh } = await import('@/components/ChatHistorySidebar');
277+
278+
act(() => {
279+
render(
280+
<ChatHistorySidebarFresh onLoadSession={vi.fn()} isCollapsed={false} />,
281+
);
282+
});
283+
284+
await act(async () => { vi.advanceTimersByTime(900); });
285+
286+
expect(screen.getByText('Sidebar unavailable')).toBeTruthy();
287+
expect(screen.queryByText('PriceTicker exploded')).toBeNull();
288+
289+
vi.doUnmock('@/components/PriceTicker');
290+
});
291+
292+
it('displays the custom retry label from the error boundary props', async () => {
293+
vi.doMock('@/components/PriceTicker', () => ({
294+
default: () => { throw new Error('forced'); },
295+
}));
296+
297+
const { default: ChatHistorySidebarFresh } = await import('@/components/ChatHistorySidebar');
298+
299+
act(() => {
300+
render(
301+
<ChatHistorySidebarFresh onLoadSession={vi.fn()} isCollapsed={false} />,
302+
);
303+
});
304+
305+
await act(async () => { vi.advanceTimersByTime(900); });
306+
307+
expect(screen.getByRole('button', { name: /reload sidebar/i })).toBeTruthy();
308+
309+
vi.doUnmock('@/components/PriceTicker');
310+
});
311+
});

dex_with_fiat_frontend/src/hooks/chatStateMachine.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,48 @@ describe('ChatStateMachine', () => {
667667
});
668668
});
669669

670+
// ── Issue #590 regression: shared INITIAL_CONTEXT must not be mutated ─────────
671+
describe('chatStateMachine race condition regression (#590)', () => {
672+
it('two independent machines do not share context state', () => {
673+
const machineA = createChatStateMachine();
674+
machineA.transition(ChatEvent.INITIALIZE_SESSION);
675+
machineA.updateContext({ messageCount: 7, hasUserCancelled: true });
676+
677+
const machineB = createChatStateMachine();
678+
machineB.transition(ChatEvent.INITIALIZE_SESSION);
679+
680+
// Machine B must start clean — not polluted by machine A's mutations
681+
expect(machineB.getState().context.messageCount).toBe(0);
682+
expect(machineB.getState().context.hasUserCancelled).toBe(false);
683+
});
684+
685+
it('action callbacks on machine A do not corrupt machine B context', () => {
686+
const machineA = createChatStateMachine();
687+
machineA.transition(ChatEvent.INITIALIZE_SESSION);
688+
machineA.transition(ChatEvent.SEND_MESSAGE);
689+
machineA.transition(ChatEvent.ANALYSIS_COMPLETE); // → ANALYZING
690+
machineA.transition(ChatEvent.ENCOUNTER_ERROR); // → ERROR
691+
machineA.updateContext({ errorMessage: 'A failed' });
692+
693+
const machineB = createChatStateMachine();
694+
machineB.transition(ChatEvent.INITIALIZE_SESSION);
695+
696+
expect(machineB.getState().context.errorMessage).toBeNull();
697+
expect(machineB.getState().state).toBe(ChatState.INITIALIZED);
698+
});
699+
700+
it('many machines created in sequence all start with zero messageCount', () => {
701+
for (let i = 0; i < 5; i++) {
702+
const m = createChatStateMachine();
703+
m.transition(ChatEvent.INITIALIZE_SESSION);
704+
m.updateContext({ messageCount: i + 10 });
705+
const fresh = createChatStateMachine();
706+
fresh.transition(ChatEvent.INITIALIZE_SESSION);
707+
expect(fresh.getState().context.messageCount).toBe(0);
708+
}
709+
});
710+
});
711+
670712
describe('chatStateMachine clipboard snapshot helpers', () => {
671713
it('formats a stable snapshot string', () => {
672714
const context: ChatMachineContext = {

dex_with_fiat_frontend/src/hooks/chatStateMachine.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const INITIAL_CONTEXT: ChatMachineContext = {
7676
needsClarification: false,
7777
clarificationQuestion: null,
7878
errorMessage: null,
79-
lastEventTime: Date.now(),
79+
lastEventTime: 0,
8080
previousState: null,
8181
};
8282

@@ -128,13 +128,29 @@ class ChatGuards {
128128
};
129129
}
130130

131+
/**
132+
* Returns a fresh context object — never mutate the module-level constant directly.
133+
*/
134+
function getInitialContext(): ChatMachineContext {
135+
return {
136+
messageCount: 0,
137+
hasUserCancelled: false,
138+
pendingTransactionData: null,
139+
needsClarification: false,
140+
clarificationQuestion: null,
141+
errorMessage: null,
142+
lastEventTime: Date.now(),
143+
previousState: null,
144+
};
145+
}
146+
131147
/**
132148
* Create and configure the chat state machine
133149
*/
134150
export function createChatStateMachine(): StateMachine<ChatState, ChatEvent, ChatMachineContext> {
135151
const config: StateMachineConfig<ChatState, ChatEvent, ChatMachineContext> = {
136152
initial: ChatState.UNINITIALIZED,
137-
context: INITIAL_CONTEXT,
153+
context: getInitialContext(),
138154
states: {
139155
[ChatState.UNINITIALIZED]: {
140156
[ChatEvent.INITIALIZE_SESSION]: {

dex_with_fiat_frontend/src/hooks/useChat.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,3 +890,82 @@ describe('useChat race condition regression (#530)', () => {
890890
harness.cleanup();
891891
});
892892
});
893+
894+
// ── Issue #663 regression: memory leak in useChat ─────────────────────────────
895+
describe('useChat memory leak regression (#663)', () => {
896+
beforeEach(() => {
897+
analyzeQueue = [];
898+
createNewSessionSpy.mockClear();
899+
});
900+
901+
afterEach(() => {
902+
vi.useRealTimers();
903+
});
904+
905+
it('cancelled message uses a unique ID — no Date.now()+1 collision', async () => {
906+
analyzeQueue = [new Promise<AnalyzeResult>(() => {})];
907+
908+
const harness = await setupHook();
909+
await flushEffects(1);
910+
911+
act(() => { void harness.api.sendMessage('slow'); });
912+
act(() => { harness.api.cancelPendingRequest(); });
913+
914+
await flushEffects(2);
915+
916+
const ids = harness.api.messages.map((m) => m.id);
917+
const unique = new Set(ids);
918+
expect(unique.size).toBe(ids.length);
919+
920+
harness.cleanup();
921+
});
922+
923+
it('onTransactionReady is NOT called after component unmounts', async () => {
924+
analyzeQueue = [
925+
{
926+
intent: 'fiat_conversion',
927+
confidence: 0.95,
928+
extractedData: { tokenIn: 'XLM' },
929+
requiredQuestions: [],
930+
suggestedResponse: 'ok',
931+
},
932+
{
933+
intent: 'fiat_conversion',
934+
confidence: 0.95,
935+
extractedData: { fiatCurrency: 'NGN' },
936+
requiredQuestions: [],
937+
suggestedResponse: 'ok',
938+
},
939+
{
940+
intent: 'fiat_conversion',
941+
confidence: 0.95,
942+
extractedData: { amountIn: '5' },
943+
requiredQuestions: [],
944+
suggestedResponse: 'ok',
945+
},
946+
];
947+
948+
const harness = await setupHook();
949+
vi.useFakeTimers();
950+
951+
const onReady = vi.fn();
952+
act(() => { harness.api.setTransactionReadyCallback(onReady); });
953+
954+
await flushEffects(1);
955+
await act(async () => { await harness.api.sendMessage('deposit'); });
956+
await flushEffects(1);
957+
await act(async () => { await harness.api.sendMessage('NGN'); });
958+
await flushEffects(1);
959+
await act(async () => { await harness.api.sendMessage('5'); });
960+
await flushEffects(1);
961+
962+
// Unmount BEFORE the 1-second timer fires
963+
harness.cleanup();
964+
965+
// Advance past the timer window
966+
act(() => { vi.advanceTimersByTime(2000); });
967+
968+
// The callback must NOT have been called after unmount
969+
expect(onReady).not.toHaveBeenCalled();
970+
});
971+
});

0 commit comments

Comments
 (0)