Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8,579 changes: 0 additions & 8,579 deletions frontend/package-lock.json

This file was deleted.

185 changes: 140 additions & 45 deletions frontend/src/components/OfflineBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,86 +9,135 @@ 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(<OfflineBanner />);
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(<OfflineBanner />);

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(<OfflineBanner />);

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(<OfflineBanner lastKnownTvl={1000000} lastKnownBalance={100} />);

// 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(<OfflineBanner />);

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(<OfflineBanner />);

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(<OfflineBanner />);
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(<OfflineBanner />);

// 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(<OfflineBanner />);

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(<OfflineBanner />);

// 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(<OfflineBanner />);

expect(screen.getByText(/Connection restored/i)).toBeInTheDocument();

// Auto dismiss after 4 seconds
act(() => {
vi.advanceTimersByTime(4000);
});

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(<OfflineBanner />);

act(() => {
setOnline(true);
window.dispatchEvent(new Event("online"));
});

const dismissBtn = screen.getByRole("button", { name: /Dismiss banner/i });
act(() => {
Expand All @@ -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(<OfflineBanner />);

expect(screen.getByText(/retrying in 5s/i)).toBeInTheDocument();

// Update countdown
vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 3 });
rerender(<OfflineBanner />);

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(<OfflineBanner />);

// 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(<OfflineBanner />);
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(<OfflineBanner />);
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(<OfflineBanner />);
expect(successContainer.textContent).toContain("✅");
});
});
84 changes: 60 additions & 24 deletions frontend/src/components/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<BannerState>(
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") {
Expand All @@ -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 (
<div
className={`offline-banner ${isOffline ? "offline-banner--error" : "offline-banner--success"}`}
role="alert"
aria-live="assertive"
className={`offline-banner ${
isOffline
? "offline-banner--error"
: isSuccess
? "offline-banner--success"
: "offline-banner--retrying"
}`}
role={isOffline ? "alert" : "status"}
aria-live={isOffline ? "assertive" : "polite"}
aria-atomic="true"
>
<div className="offline-banner__content flex justify-between items-center">
<div className="flex items-center gap-sm">
<span className="offline-banner__icon" aria-hidden="true">
{isOffline ? "⚠️" : "✅"}
{isOffline ? "⚠️" : isSuccess ? "✅" : "🔄"}
</span>
<span>
{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…"
}
</span>
{isOffline && (lastKnownTvl !== undefined || lastKnownBalance !== undefined) && (
<span className="offline-banner__data">
Expand All @@ -78,7 +114,7 @@ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: Offlin
</span>
)}
</div>
{!isOffline && (
{isSuccess && (
<button
type="button"
className="offline-banner__dismiss"
Expand Down
Loading
Loading