From e04118917e8483ef49dce07b7f24bfe6ddd9531c Mon Sep 17 00:00:00 2001 From: Amas-01 Date: Mon, 1 Jun 2026 22:29:54 +0100 Subject: [PATCH 1/2] feat: add offline banner and auto-retry indicator for failed API polling (#511) - NEW: useNetworkStatus hook for browser connectivity detection * Subscribes to window online/offline events * Returns { isOnline: boolean } * Testable with fake timers and mocked navigator.onLine - NEW: useRetryState hook for tracking React Query retry cycles * Monitors query cache for error states * Computes seconds until next retry attempt * Returns { isRetrying: boolean, secondsUntilRetry: number | null } - ENHANCED: OfflineBanner component with retry countdown indicator * Offline state: Non-dismissible warning banner when device is offline * Retrying state: Shows countdown "Reconnecting... retrying in Xs" * Success state: Brief auto-dismissing success message on reconnection * Hidden state: Renders nothing when online and not retrying * ARIA attributes: role="alert" for offline (assertive), role="status" for retrying/success - UPDATED: Polling pause/resume for network awareness * useVaultData.ts: useVaultSummary(enabled: isOnline) pauses polling when offline * useFeeEstimate.ts: Added enabledNetworkPolling parameter for XLM price polling * useTvlTicker.ts: Added enabled parameter to pause TVL polling when offline * VaultContext.tsx: Passes isOnline to useVaultSummary * TvlTicker.tsx: Uses useNetworkStatus for polling control * VaultDashboard.tsx: Passes isOnline to useFeeEstimate - STYLING: Added .offline-banner--retrying variant (amber background) * Z-index: 1000 (above page content, below modals) * Auto-dismisses success after 4 seconds * Responsive layout with no text truncation - TESTS: Comprehensive unit tests * useNetworkStatus.test.ts: Online/offline transitions, event listener cleanup * useRetryState.test.ts: Query cache subscription, countdown logic * OfflineBanner.test.tsx: Enhanced tests for all banner states, ARIA attributes, icons Polling implementations now pause automatically when device goes offline and resume on reconnection. Users see a clear offline banner and retry countdown, improving connectivity awareness without manual intervention. Closes #511 --- .../src/components/OfflineBanner.test.tsx | 185 +++++++++++++----- frontend/src/components/OfflineBanner.tsx | 84 +++++--- frontend/src/components/TvlTicker.tsx | 18 +- frontend/src/components/VaultDashboard.tsx | 5 +- frontend/src/context/VaultContext.tsx | 4 +- frontend/src/hooks/useFeeEstimate.ts | 20 +- frontend/src/hooks/useNetworkStatus.test.ts | 97 +++++++++ frontend/src/hooks/useNetworkStatus.ts | 38 ++++ frontend/src/hooks/useRetryState.test.ts | 68 +++++++ frontend/src/hooks/useRetryState.ts | 109 +++++++++++ frontend/src/hooks/useTvlTicker.ts | 12 +- frontend/src/hooks/useVaultData.ts | 16 +- frontend/src/index.css | 4 + 13 files changed, 576 insertions(+), 84 deletions(-) create mode 100644 frontend/src/hooks/useNetworkStatus.test.ts create mode 100644 frontend/src/hooks/useNetworkStatus.ts create mode 100644 frontend/src/hooks/useRetryState.test.ts create mode 100644 frontend/src/hooks/useRetryState.ts diff --git a/frontend/src/components/OfflineBanner.test.tsx b/frontend/src/components/OfflineBanner.test.tsx index 3bb8bb0c..37133fce 100644 --- a/frontend/src/components/OfflineBanner.test.tsx +++ b/frontend/src/components/OfflineBanner.test.tsx @@ -9,71 +9,124 @@ vi.mock("../lib/queryClient", () => ({ }, })); -describe("OfflineBanner", () => { - let originalOnLine: boolean; +// Mock the hooks +vi.mock("../hooks/useNetworkStatus", () => ({ + useNetworkStatus: vi.fn(), +})); + +vi.mock("../hooks/useRetryState", () => ({ + useRetryState: vi.fn(), +})); +import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useRetryState } from "../hooks/useRetryState"; + +describe("OfflineBanner", () => { beforeEach(() => { - originalOnLine = navigator.onLine; vi.useFakeTimers(); + // Default mocks + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); }); afterEach(() => { - Object.defineProperty(navigator, 'onLine', { - value: originalOnLine, - configurable: true, - }); vi.clearAllMocks(); vi.useRealTimers(); }); - const setOnline = (value: boolean) => { - Object.defineProperty(navigator, 'onLine', { - value, - configurable: true, - }); - }; - - it("should hide by default when online", () => { - setOnline(true); + it("should hide by default when online and not retrying", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - it("should show offline banner when offline event fires", () => { - setOnline(true); + it("should show offline banner when offline", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); render(); - - act(() => { - setOnline(false); - window.dispatchEvent(new Event("offline")); - }); - expect(screen.getByText(/You are currently offline/i)).toBeInTheDocument(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); // Non-dismissible + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + // Offline banner should NOT be dismissible + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); - it("should show success banner and invalidate queries on online event, then auto-dismiss", () => { - setOnline(false); + it("should have role alert and aria-live assertive when offline", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); render(); - - expect(screen.getByText(/You are currently offline/i)).toBeInTheDocument(); - act(() => { - setOnline(true); - window.dispatchEvent(new Event("online")); - }); + const banner = screen.getByRole("alert"); + expect(banner).toHaveAttribute("aria-live", "assertive"); + expect(banner).toHaveAttribute("aria-atomic", "true"); + }); + + it("should display offline message with last known data", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + render(); - // Should show success banner + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + expect(screen.getByText(/TVL:.*1,000,000/i)).toBeInTheDocument(); + expect(screen.getByText(/Balance:.*100/i)).toBeInTheDocument(); + }); + + it("should show retrying state with countdown when retrying", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + render(); + + expect(screen.getByText(/Reconnecting.*retrying in 5s/i)).toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("should have role status and aria-live polite when retrying", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + render(); + + const banner = screen.getByRole("status"); + expect(banner).toHaveAttribute("aria-live", "polite"); + expect(banner).toHaveAttribute("aria-atomic", "true"); + }); + + it("should show success banner when transitioning from offline to online", () => { + // Start offline + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + const { rerender } = render(); + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + + // Transition to online (simulate reconnection) + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + rerender(); + + // Should show success message expect(screen.getByText(/Connection restored/i)).toBeInTheDocument(); - - // Should invalidate queries expect(queryClient.invalidateQueries).toHaveBeenCalled(); + }); + + it("should have role status and aria-live polite for success message", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); + + const banner = screen.getByRole("status"); + expect(banner).toHaveAttribute("aria-live", "polite"); + }); + + it("should show dismissible button only on success state", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); - // Should be dismissible manually const dismissBtn = screen.getByRole("button", { name: /Dismiss banner/i }); expect(dismissBtn).toBeInTheDocument(); + }); + + it("should auto-dismiss success message after 4 seconds", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); + + expect(screen.getByText(/Connection restored/i)).toBeInTheDocument(); - // Auto dismiss after 4 seconds act(() => { vi.advanceTimersByTime(4000); }); @@ -81,14 +134,10 @@ describe("OfflineBanner", () => { expect(screen.queryByText(/Connection restored/i)).not.toBeInTheDocument(); }); - it("can be manually dismissed when online_success", () => { - setOnline(false); + it("should manually dismiss success message", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); render(); - - act(() => { - setOnline(true); - window.dispatchEvent(new Event("online")); - }); const dismissBtn = screen.getByRole("button", { name: /Dismiss banner/i }); act(() => { @@ -97,4 +146,50 @@ describe("OfflineBanner", () => { expect(screen.queryByText(/Connection restored/i)).not.toBeInTheDocument(); }); + + it("should update countdown when secondsUntilRetry changes", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + const { rerender } = render(); + + expect(screen.getByText(/retrying in 5s/i)).toBeInTheDocument(); + + // Update countdown + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 3 }); + rerender(); + + expect(screen.getByText(/retrying in 3s/i)).toBeInTheDocument(); + }); + + it("should not render when hidden (online, not retrying, not recently reconnected)", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + const { container } = render(); + + // The component will not render the banner initially since there's no transition + expect(container.firstChild).toBeNull(); + }); + + it("should use correct icons for each state", () => { + // Offline state - warning icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + const { container: offlineContainer } = render(); + expect(offlineContainer.textContent).toContain("⚠️"); + + vi.clearAllMocks(); + + // Retrying state - spinner icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + const { container: retryingContainer } = render(); + expect(retryingContainer.textContent).toContain("🔄"); + + vi.clearAllMocks(); + + // Success state - check mark icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + const { container: successContainer } = render(); + expect(successContainer.textContent).toContain("✅"); + }); }); diff --git a/frontend/src/components/OfflineBanner.tsx b/frontend/src/components/OfflineBanner.tsx index 02c5e354..3c7c7b4d 100644 --- a/frontend/src/components/OfflineBanner.tsx +++ b/frontend/src/components/OfflineBanner.tsx @@ -1,48 +1,70 @@ import { useEffect, useState, useCallback } from "react"; import { queryClient } from "../lib/queryClient"; +import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useRetryState } from "../hooks/useRetryState"; interface OfflineBannerProps { lastKnownTvl?: number; lastKnownBalance?: number; } -type BannerState = "hidden" | "offline" | "online_success"; +type BannerState = "hidden" | "offline" | "retrying" | "online_success"; +/** + * OfflineBanner component displays connectivity state and retry information. + * - Offline state: Shows warning when device is disconnected (non-dismissible) + * - Retrying state: Shows countdown to next retry attempt when queries are retrying + * - Success state: Brief success message that auto-dismisses after 3-4 seconds + * - Hidden state: Not rendered + * + * Accessibility: + * - Uses role="alert" for offline (high urgency) and role="status" for retrying/success + * - Uses aria-live="assertive" for offline and "polite" for retrying/success + * - Countdown is screen-reader accessible via aria-live region updates + * + * @param lastKnownTvl - Last known TVL to display while offline + * @param lastKnownBalance - Last known balance to display while offline + */ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: OfflineBannerProps) { + const { isOnline } = useNetworkStatus(); + const { isRetrying, secondsUntilRetry } = useRetryState(); + const [bannerState, setBannerState] = useState( - typeof navigator !== "undefined" && !navigator.onLine ? "offline" : "hidden" + !isOnline ? "offline" : "hidden" ); useEffect(() => { let timeoutId: number; - const handleOnline = () => { - // Transition to success state immediately + if (!isOnline) { + // Device went offline + window.clearTimeout(timeoutId); + setBannerState("offline"); + } else if (isRetrying) { + // Online but queries are retrying + setBannerState("retrying"); + } else if (bannerState === "offline") { + // Transitioned from offline to online setBannerState("online_success"); // Instantly trigger fresh HTTP requests for all active dashboard widgets queryClient.invalidateQueries(); - // Auto-fade out after 3-5 seconds + // Auto-fade out after 4 seconds timeoutId = window.setTimeout(() => { setBannerState("hidden"); }, 4000); - }; - - const handleOffline = () => { - window.clearTimeout(timeoutId); - setBannerState("offline"); - }; - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); + } else if (bannerState === "online_success" && !isRetrying) { + // Continue showing success state if retrying ended + timeoutId = window.setTimeout(() => { + setBannerState("hidden"); + }, 4000); + } return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); window.clearTimeout(timeoutId); }; - }, []); + }, [isOnline, isRetrying, bannerState]); const dismissBanner = useCallback(() => { if (bannerState === "online_success") { @@ -53,22 +75,36 @@ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: Offlin if (bannerState === "hidden") return null; const isOffline = bannerState === "offline"; + const isSuccess = bannerState === "online_success"; + const showRetrying = bannerState === "retrying"; return (
{isOffline - ? "You are currently offline. Attempting to reconnect..." - : "Connection restored! Updating dashboard..."} + ? "You are offline. Polling is paused." + : isSuccess + ? "Connection restored! Updating dashboard..." + : showRetrying && secondsUntilRetry !== null + ? `Reconnecting… retrying in ${secondsUntilRetry}s` + : "Reconnecting…" + } {isOffline && (lastKnownTvl !== undefined || lastKnownBalance !== undefined) && ( @@ -78,7 +114,7 @@ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: Offlin )}
- {!isOffline && ( + {isSuccess && (