Skip to content

Commit 56b182f

Browse files
authored
Merge pull request #1286 from Immex171/feat/frontend-reliiability-optimistic-ui-retry
# Frontend Reliability Enhancements: Optimistic UI & Request Retry
2 parents 4a49859 + 6f1551c commit 56b182f

7 files changed

Lines changed: 1170 additions & 224 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { render, screen, waitFor } from '@testing-library/react';
4+
import OfflineStatusBanner from './OfflineStatusBanner';
5+
import * as offlineMessageQueue from '@/lib/offlineMessageQueue';
6+
7+
// Mock dependencies
8+
vi.mock('@/hooks/useOnlineStatus', () => ({
9+
useOnlineStatus: vi.fn(() => ({
10+
isOnline: true,
11+
wasOffline: false,
12+
resetWasOffline: vi.fn(),
13+
})),
14+
}));
15+
16+
vi.mock('@/hooks/useToast', () => ({
17+
useToast: vi.fn(() => ({
18+
addToast: vi.fn(),
19+
})),
20+
}));
21+
22+
vi.mock('@/lib/offlineStatusSchema', () => ({
23+
offlineStatusToastSchema: {
24+
safeParse: vi.fn(() => ({ success: true, data: {} })),
25+
},
26+
}));
27+
28+
vi.mock('@/lib/offlineMessageQueue', () => ({
29+
subscribeToQueuedMessageCount: vi.fn(),
30+
setQueuedMessageCount: vi.fn(),
31+
getQueuedMessageCount: vi.fn(() => 0),
32+
}));
33+
34+
describe('OfflineStatusBanner - Optimistic UI Updates', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
vi.useFakeTimers();
38+
});
39+
40+
afterEach(() => {
41+
vi.restoreAllMocks();
42+
vi.useRealTimers();
43+
});
44+
45+
it('should show banner immediately when going offline', async () => {
46+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
47+
(useOnlineStatus as any).mockReturnValue({
48+
isOnline: false,
49+
wasOffline: false,
50+
resetWasOffline: vi.fn(),
51+
});
52+
53+
render(<OfflineStatusBanner />);
54+
55+
await waitFor(() => {
56+
expect(screen.getByRole('status')).toBeInTheDocument();
57+
});
58+
59+
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
60+
});
61+
62+
it('should show reconnecting state when coming back online', async () => {
63+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
64+
let isOnline = false;
65+
66+
(useOnlineStatus as any).mockImplementation(() => ({
67+
get isOnline() { return isOnline; },
68+
wasOffline: true,
69+
resetWasOffline: vi.fn(),
70+
}));
71+
72+
const { rerender } = render(<OfflineStatusBanner />);
73+
74+
await waitFor(() => {
75+
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
76+
});
77+
78+
// Simulate coming back online
79+
isOnline = true;
80+
rerender(<OfflineStatusBanner />);
81+
82+
await waitFor(() => {
83+
expect(screen.getByText(/Reconnecting/i)).toBeInTheDocument();
84+
});
85+
});
86+
87+
it('should display optimistic pending count', async () => {
88+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
89+
(useOnlineStatus as any).mockReturnValue({
90+
isOnline: false,
91+
wasOffline: false,
92+
resetWasOffline: vi.fn(),
93+
});
94+
95+
(offlineMessageQueue.getQueuedMessageCount as any).mockReturnValue(3);
96+
97+
render(<OfflineStatusBanner />);
98+
99+
await waitFor(() => {
100+
expect(screen.getByText(/3 messages waiting to send/i)).toBeInTheDocument();
101+
});
102+
});
103+
104+
it('should hide banner after reconnection delay', async () => {
105+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
106+
let isOnline = false;
107+
108+
(useOnlineStatus as any).mockImplementation(() => ({
109+
get isOnline() { return isOnline; },
110+
wasOffline: true,
111+
resetWasOffline: vi.fn(),
112+
}));
113+
114+
const { rerender } = render(<OfflineStatusBanner />);
115+
116+
await waitFor(() => {
117+
expect(screen.getByRole('status')).toBeInTheDocument();
118+
});
119+
120+
// Simulate coming back online
121+
isOnline = true;
122+
rerender(<OfflineStatusBanner />);
123+
124+
act(() => {
125+
vi.advanceTimersByTime(500);
126+
});
127+
128+
await waitFor(() => {
129+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
130+
});
131+
});
132+
133+
it('should update aria-label based on connection state', async () => {
134+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
135+
let isOnline = false;
136+
137+
(useOnlineStatus as any).mockImplementation(() => ({
138+
get isOnline() { return isOnline; },
139+
wasOffline: true,
140+
resetWasOffline: vi.fn(),
141+
}));
142+
143+
const { rerender } = render(<OfflineStatusBanner />);
144+
145+
await waitFor(() => {
146+
expect(screen.getByLabelText('Offline status')).toBeInTheDocument();
147+
});
148+
149+
isOnline = true;
150+
rerender(<OfflineStatusBanner />);
151+
152+
await waitFor(() => {
153+
expect(screen.getByLabelText('Reconnecting')).toBeInTheDocument();
154+
});
155+
});
156+
157+
it('should show loading skeleton initially when online', async () => {
158+
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
159+
(useOnlineStatus as any).mockReturnValue({
160+
isOnline: true,
161+
wasOffline: false,
162+
resetWasOffline: vi.fn(),
163+
});
164+
165+
render(<OfflineStatusBanner />);
166+
167+
// Should show loading skeleton initially
168+
const skeleton = document.querySelector('[aria-hidden="true"]');
169+
expect(skeleton).toBeInTheDocument();
170+
171+
act(() => {
172+
vi.advanceTimersByTime(300);
173+
});
174+
175+
await waitFor(() => {
176+
expect(document.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument();
177+
});
178+
});
179+
});

Dechat/dex_with_fiat_frontend/src/components/OfflineStatusBanner.tsx

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
'use client';
22

3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useState, useCallback, useRef } from 'react';
44
import { AlertTriangle, WifiOff } from 'lucide-react';
55
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
66
import { useToast } from '@/hooks/useToast';
77
import { offlineStatusToastSchema } from '@/lib/offlineStatusSchema';
8-
import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue';
8+
import { subscribeToQueuedMessageCount, setQueuedMessageCount, getQueuedMessageCount } from '@/lib/offlineMessageQueue';
99

1010
/**
1111
* Offline Status Banner Component
1212
* Shows when the user loses internet connection
1313
* Displays accessibility-compliant live region
14+
* Implements optimistic UI updates for immediate feedback
1415
*/
1516
export default function OfflineStatusBanner() {
1617
const { isOnline, wasOffline, resetWasOffline } = useOnlineStatus();
1718
const { addToast } = useToast();
1819
const [showBanner, setShowBanner] = useState(false);
1920
const [isLoading, setIsLoading] = useState(true);
2021
const [pendingCount, setPendingCount] = useState(0);
22+
const [optimisticPendingCount, setOptimisticPendingCount] = useState(0);
23+
const [isReconnecting, setIsReconnecting] = useState(false);
24+
const previousOnlineState = useRef<boolean>(true);
2125

2226
useEffect(() => {
2327
const timer = setTimeout(() => {
@@ -28,13 +32,33 @@ export default function OfflineStatusBanner() {
2832
}, []);
2933

3034
useEffect(() => {
31-
return subscribeToQueuedMessageCount(setPendingCount);
35+
return subscribeToQueuedMessageCount((count) => {
36+
setPendingCount(count);
37+
setOptimisticPendingCount(count);
38+
});
39+
}, []);
40+
41+
// Optimistic update: increment pending count immediately when message is queued
42+
const optimisticallyIncrementPending = useCallback(() => {
43+
setOptimisticPendingCount((prev: number) => prev + 1);
44+
setQueuedMessageCount(getQueuedMessageCount() + 1);
45+
}, []);
46+
47+
// Optimistic update: decrement pending count immediately when message is sent
48+
const optimisticallyDecrementPending = useCallback(() => {
49+
setOptimisticPendingCount((prev: number) => Math.max(0, prev - 1));
50+
setQueuedMessageCount(Math.max(0, getQueuedMessageCount() - 1));
3251
}, []);
3352

3453
useEffect(() => {
35-
if (!isOnline) {
54+
// Optimistic UI: Show banner immediately when going offline
55+
if (!isOnline && previousOnlineState.current) {
3656
setShowBanner(true);
37-
} else if (wasOffline && isOnline) {
57+
setIsReconnecting(false);
58+
}
59+
// Optimistic UI: Hide banner immediately when coming back online
60+
else if (isOnline && !previousOnlineState.current) {
61+
setIsReconnecting(true);
3862
// Show toast when coming back online
3963
const toastOptions = {
4064
message:
@@ -58,9 +82,15 @@ export default function OfflineStatusBanner() {
5882
addToast(errorMessage);
5983
}
6084

61-
setShowBanner(false);
62-
resetWasOffline();
85+
// Optimistically hide banner after short delay
86+
setTimeout(() => {
87+
setShowBanner(false);
88+
setIsReconnecting(false);
89+
resetWasOffline();
90+
}, 500);
6391
}
92+
93+
previousOnlineState.current = isOnline;
6494
}, [isOnline, wasOffline, addToast, resetWasOffline]);
6595

6696
if (isLoading && isOnline) {
@@ -85,20 +115,30 @@ export default function OfflineStatusBanner() {
85115
role="status"
86116
aria-live="polite"
87117
aria-atomic="true"
88-
aria-label="Offline status"
89-
className="fixed top-0 left-0 right-0 z-50 border-b-2 shadow-md bg-[var(--color-danger)] border-[color-mix(in_srgb,var(--color-danger)_80%,black)]"
118+
aria-label={isReconnecting ? "Reconnecting" : "Offline status"}
119+
className={`fixed top-0 left-0 right-0 z-50 border-b-2 shadow-md transition-all duration-300 ${
120+
isReconnecting
121+
? 'bg-[var(--color-success)] border-[color-mix(in_srgb,var(--color-success)_80%,black)]'
122+
: 'bg-[var(--color-danger)] border-[color-mix(in_srgb,var(--color-danger)_80%,black)]'
123+
}`}
90124
>
91125
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center gap-3">
92126
<div className="shrink-0" aria-hidden="true">
93-
<WifiOff className="w-5 h-5 animate-pulse text-white" />
127+
{isReconnecting ? (
128+
<WifiOff className="w-5 h-5 text-white" />
129+
) : (
130+
<WifiOff className="w-5 h-5 animate-pulse text-white" />
131+
)}
94132
</div>
95133
<div className="flex-1">
96134
<p className="text-sm font-semibold text-white">
97-
You are offline. Messages will be sent when you reconnect.
135+
{isReconnecting
136+
? 'Reconnecting...'
137+
: 'You are offline. Messages will be sent when you reconnect.'}
98138
</p>
99-
{pendingCount > 0 && (
139+
{optimisticPendingCount > 0 && (
100140
<p className="text-xs text-white/90 mt-0.5">
101-
{pendingCount} message{pendingCount === 1 ? '' : 's'} waiting to
141+
{optimisticPendingCount} message{optimisticPendingCount === 1 ? '' : 's'} waiting to
102142
send
103143
</p>
104144
)}

0 commit comments

Comments
 (0)