From 83fae6f1c26e776345ead950d07246e83e1c7cc3 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:54:03 +0100 Subject: [PATCH] [#448] added reconnection logic to websocket (#472) * [#448] added reconnection logic to websocket * [#448] added test Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../features/websocket/WebSocketGate.test.tsx | 484 ++++++++++++++++++ .../websocket/useWebSocketReconnect.test.tsx | 283 ++++++++++ src/utils/apiUtils.ts | 15 +- src/utils/getRetryDelay.ts | 13 + src/websocket/WebSocketGate.tsx | 180 ++++++- .../lifecycle/useWebSocketReconnect.ts | 56 ++ 6 files changed, 1007 insertions(+), 24 deletions(-) create mode 100644 __test__/features/websocket/useWebSocketReconnect.test.tsx create mode 100644 src/utils/getRetryDelay.ts create mode 100644 src/websocket/connection/lifecycle/useWebSocketReconnect.ts diff --git a/__test__/features/websocket/WebSocketGate.test.tsx b/__test__/features/websocket/WebSocketGate.test.tsx index b14fd98..7a3a90e 100644 --- a/__test__/features/websocket/WebSocketGate.test.tsx +++ b/__test__/features/websocket/WebSocketGate.test.tsx @@ -48,6 +48,16 @@ describe("WebSocketGate", () => { }); describe("Authentication", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + jest.resetModules(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); it("should not create connection when user is not authenticated", () => { const unauthStore = createMockStore(null, null); @@ -197,6 +207,480 @@ describe("WebSocketGate", () => { }); }); + describe("Reconnection Logic", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + jest.resetModules(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it("should trigger reconnection on unexpected close (code 1006)", async () => { + jest.useFakeTimers(); + const consoleWarn = jest.spyOn(console, "warn").mockImplementation(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Simulate unexpected close + await act(async () => { + onCloseCallback?.( + new CloseEvent("close", { code: 1006, reason: "Connection lost" }) + ); + }); + + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("WebSocket closed unexpectedly (code: 1006") + ); + + // Advance timer to trigger reconnection + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(2); + }); + + consoleWarn.mockRestore(); + jest.useRealTimers(); + }); + + it("should NOT reconnect on normal close (code 1000)", async () => { + jest.useFakeTimers(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Simulate normal close + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1000 })); + }); + + // Advance timer + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + // Should NOT reconnect + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("should NOT reconnect on going away (code 1001)", async () => { + jest.useFakeTimers(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Simulate going away + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1001 })); + }); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("should reset reconnection attempts counter on successful connection", async () => { + jest.useFakeTimers(); + const consoleLog = jest.spyOn(console, "log").mockImplementation(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // First failure + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + jest.advanceTimersByTime(1000); + }); + + // Second failure + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + jest.advanceTimersByTime(2000); + }); + + // Success - should log and reset counter + await waitFor(() => { + expect(consoleLog).toHaveBeenCalledWith( + "WebSocket connected successfully" + ); + }); + + // Next failure should start from attempt 1 again + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + // Should schedule with initial delay (attempt 1) + expect(consoleLog).toHaveBeenCalledWith( + expect.stringContaining("(attempt 1/10)") + ); + + consoleLog.mockRestore(); + jest.useRealTimers(); + }); + + it("should not reconnect if authentication is lost during reconnection timeout", async () => { + jest.useFakeTimers(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Trigger reconnection + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + // Lose authentication before timeout fires + const unauthStore = createMockStore(null, null); + rerender( + + + + ); + + // Advance timer + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Should NOT reconnect (still only 1 connection) + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("should clear reconnection timeout on component unmount", async () => { + jest.useFakeTimers(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + const { unmount } = render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Trigger reconnection + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + // Unmount before timeout fires + unmount(); + + // Advance timer + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Should NOT reconnect after unmount + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("should re-sync calendars after reconnection", async () => { + jest.useFakeTimers(); + localStorage.setItem( + "selectedCalendars", + JSON.stringify(["cal1", "cal2"]) + ); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ + "/calendars/cal1", + "/calendars/cal2", + ]); + }); + + jest.clearAllMocks(); + + // Close and reconnect + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + jest.advanceTimersByTime(1000); + }); + + // Should re-register all calendars after reconnection + await waitFor(() => { + expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ + "/calendars/cal1", + "/calendars/cal2", + ]); + }); + jest.useRealTimers(); + }); + }); + + describe("Browser Online/Offline Events", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + jest.resetModules(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it("should trigger immediate reconnection when browser goes online", async () => { + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Close connection + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + // Trigger online event + await act(async () => { + window.dispatchEvent(new Event("online")); + }); + + // Should trigger immediate reconnection (no delay) + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(2); + }); + }); + + it("should pause reconnection attempts when browser goes offline", async () => { + jest.useFakeTimers(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Close connection + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + // Go offline before reconnection fires + await act(async () => { + window.dispatchEvent(new Event("offline")); + }); + + // Advance timer - should NOT reconnect + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("should not reconnect when online event fires if already connected", async () => { + (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Trigger online event while connected + await act(async () => { + window.dispatchEvent(new Event("online")); + }); + + // Should NOT trigger new connection + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + it("should reset attempt counter when online event fires", async () => { + jest.useFakeTimers(); + const consoleLog = jest.spyOn(console, "log").mockImplementation(); + let onCloseCallback: Function | undefined; + + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); + + render( + + + + ); + + await waitFor(() => { + expect(createWebSocketConnection).toHaveBeenCalledTimes(1); + }); + + // Multiple failed attempts + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + jest.advanceTimersByTime(1000); + }); + + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + jest.advanceTimersByTime(2000); + }); + + // Go offline then online + await act(async () => { + window.dispatchEvent(new Event("offline")); + window.dispatchEvent(new Event("online")); + }); + + // Next reconnection should start from attempt 1 + await act(async () => { + onCloseCallback?.(new CloseEvent("close", { code: 1006 })); + }); + + expect(consoleLog).toHaveBeenCalledWith( + expect.stringContaining("(attempt 1/10)") + ); + + consoleLog.mockRestore(); + jest.useRealTimers(); + }); + }); + describe("Calendar Registration", () => { it("should not register if socket is not open", async () => { localStorage.setItem( diff --git a/__test__/features/websocket/useWebSocketReconnect.test.tsx b/__test__/features/websocket/useWebSocketReconnect.test.tsx new file mode 100644 index 0000000..c573b0a --- /dev/null +++ b/__test__/features/websocket/useWebSocketReconnect.test.tsx @@ -0,0 +1,283 @@ +import { getRetryDelay } from "@/utils/getRetryDelay"; +import { + MAX_RECONNECT_ATTEMPTS, + RECONNECT_CONFIG, + useWebSocketReconnect, +} from "@/websocket/connection/lifecycle/useWebSocketReconnect"; +import { act, renderHook } from "@testing-library/react"; +import { MutableRefObject } from "react"; + +// Mock the retry delay utility +jest.mock("@/utils/getRetryDelay"); +const mockGetRetryDelay = getRetryDelay as jest.MockedFunction< + typeof getRetryDelay +>; + +describe("useWebSocketReconnect", () => { + let reconnectTimeoutRef: MutableRefObject; + let isAuthenticatedRef: MutableRefObject; + let reconnectAttemptsRef: MutableRefObject; + let setShouldConnect: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + reconnectTimeoutRef = { current: null }; + isAuthenticatedRef = { current: true }; + reconnectAttemptsRef = { current: 0 }; + setShouldConnect = jest.fn(); + mockGetRetryDelay.mockReturnValue(1000); // Default 1 second delay + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe("scheduleReconnect", () => { + it("should schedule a reconnection with correct delay", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, // isAuthenticated + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.scheduleReconnect(); + }); + + expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG); + expect(reconnectTimeoutRef.current).not.toBeNull(); + expect(setShouldConnect).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function)); + expect(reconnectAttemptsRef.current).toBe(1); + }); + + it("should not schedule reconnection if not authenticated", () => { + isAuthenticatedRef = { current: false }; + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, // Not authenticated + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.scheduleReconnect(); + }); + + expect(reconnectTimeoutRef.current).toBeNull(); + expect(mockGetRetryDelay).not.toHaveBeenCalled(); + }); + + it("should stop after MAX_RECONNECT_ATTEMPTS", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS; + + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.scheduleReconnect(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS})` + ) + ); + expect(reconnectTimeoutRef.current).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + + it("should increment attempt counter on each reconnection", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + expect(reconnectAttemptsRef.current).toBe(0); + + act(() => { + result.current.scheduleReconnect(); + jest.advanceTimersByTime(1000); + }); + + expect(reconnectAttemptsRef.current).toBe(1); + + act(() => { + result.current.scheduleReconnect(); + jest.advanceTimersByTime(1000); + }); + + expect(reconnectAttemptsRef.current).toBe(2); + }); + + it("should toggle setShouldConnect correctly", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.scheduleReconnect(); + jest.advanceTimersByTime(1000); + }); + + expect(setShouldConnect).toHaveBeenCalledTimes(1); + + // Test the toggle function + const toggleFn = setShouldConnect.mock.calls[0][0]; + expect(toggleFn(false)).toBe(true); + expect(toggleFn(true)).toBe(false); + }); + }); + + describe("clearReconnectTimeout", () => { + it("should clear pending timeout", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.scheduleReconnect(); + }); + + expect(reconnectTimeoutRef.current).not.toBeNull(); + + act(() => { + result.current.clearReconnectTimeout(); + }); + + expect(reconnectTimeoutRef.current).toBeNull(); + + // Timeout should not fire + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(setShouldConnect).not.toHaveBeenCalled(); + }); + + it("should handle multiple clears gracefully", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + act(() => { + result.current.clearReconnectTimeout(); + result.current.clearReconnectTimeout(); + result.current.clearReconnectTimeout(); + }); + + expect(reconnectTimeoutRef.current).toBeNull(); + }); + + it("should clear timeout before scheduling new one", () => { + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + // Schedule first reconnection + act(() => { + result.current.scheduleReconnect(); + }); + + const firstTimeout = reconnectTimeoutRef.current; + expect(firstTimeout).not.toBeNull(); + + // Schedule second reconnection (should clear first) + act(() => { + result.current.scheduleReconnect(); + }); + + const secondTimeout = reconnectTimeoutRef.current; + expect(secondTimeout).not.toBeNull(); + expect(secondTimeout).not.toBe(firstTimeout); + + // Only second timeout should fire + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(setShouldConnect).toHaveBeenCalledTimes(1); + }); + }); + it("should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + const { result } = renderHook(() => + useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ) + ); + + // Simulate 10 reconnection attempts + for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) { + act(() => { + result.current.scheduleReconnect(); + jest.advanceTimersByTime( + mockGetRetryDelay.mock.results[i]?.value || 1000 + ); + }); + } + + expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS); + expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); + + // Try to schedule one more reconnection - should fail + act(() => { + result.current.scheduleReconnect(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.` + ); + expect(reconnectTimeoutRef.current).toBeNull(); + expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); // Should not increment + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index c00536b..7b39618 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -1,25 +1,22 @@ import { Auth } from "@/features/User/oidcAuth"; import ky from "ky"; +import { getRetryDelay } from "./getRetryDelay"; const RETRY_CONFIG = { maxRetries: 10, initialDelay: 1000, maxDelay: 120000, }; - -function getRetryDelay(attemptNumber: number): number { - const cap = RETRY_CONFIG.maxDelay; - const base = RETRY_CONFIG.initialDelay * Math.pow(2, attemptNumber); - const jitter = 0.5 + Math.random(); - return Math.min(cap, base * jitter); -} - export const api = ky.extend({ prefixUrl: (window as any).CALENDAR_BASE_URL, retry: { limit: RETRY_CONFIG.maxRetries, backoffLimit: RETRY_CONFIG.maxDelay, - delay: (attemptCount) => getRetryDelay(attemptCount - 1), + delay: (attemptCount) => + getRetryDelay(attemptCount - 1, { + initialDelay: RETRY_CONFIG.initialDelay, + maxDelay: RETRY_CONFIG.maxDelay, + }), }, hooks: { beforeRequest: [ diff --git a/src/utils/getRetryDelay.ts b/src/utils/getRetryDelay.ts new file mode 100644 index 0000000..da1e3d2 --- /dev/null +++ b/src/utils/getRetryDelay.ts @@ -0,0 +1,13 @@ +export type RetryBackoffConfig = { + initialDelay: number; + maxDelay: number; +}; + +export function getRetryDelay( + attempt: number, + { initialDelay, maxDelay }: RetryBackoffConfig +) { + const base = initialDelay * Math.pow(2, attempt); + const jitter = 0.5 + Math.random(); + return Math.min(maxDelay, base * jitter); +} diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx index 483fffb..1537ce7 100644 --- a/src/websocket/WebSocketGate.tsx +++ b/src/websocket/WebSocketGate.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { WebSocketWithCleanup } from "./connection"; import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection"; import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection"; +import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect"; import { updateCalendars } from "./messaging/updateCalendars"; import { syncCalendarRegistrations } from "./operations"; @@ -12,13 +13,25 @@ export function WebSocketGate() { const socketRef = useRef(null); const previousCalendarListRef = useRef([]); const previousTempCalendarListRef = useRef([]); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const isConnectingRef = useRef(false); + + const connectTimeoutRef = useRef(null); + const CONNECT_TIMEOUT_MS = 10_000; + + const hadSocketBeforeRef = useRef(false); + const justReconnectedRef = useRef(false); + const dispatch = useAppDispatch(); const isAuthenticated = useAppSelector((state) => Boolean(state.user.userData && state.user.tokens) ); + const isAuthenticatedRef = useRef(isAuthenticated); const [isSocketOpen, setIsSocketOpen] = useState(false); const isPending = useAppSelector((state) => state.calendars.pending); + const [shouldConnect, setShouldConnect] = useState(false); const calendarList = useSelectedCalendars(); const tempCalendarList = Object.keys( @@ -48,12 +61,34 @@ export function WebSocketGate() { [dispatch] ); - const onClose = useCallback((event: CloseEvent) => { - // Socket already cleaned up by internal handler before this callback fires - socketRef.current = null; - setIsSocketOpen(false); - // TODO: Add reconnection logic here - }, []); + const { scheduleReconnect, clearReconnectTimeout } = useWebSocketReconnect( + reconnectTimeoutRef, + isAuthenticatedRef, + reconnectAttemptsRef, + setShouldConnect + ); + + const onClose = useCallback( + (event: CloseEvent) => { + // Socket already cleaned up by internal handler before this callback fires + socketRef.current = null; + setIsSocketOpen(false); + + // Only attempt reconnection if it wasn't a normal closure + // Code 1000 = normal closure, 1001 = going away (e.g., page unload) + if (event.code !== 1000 && event.code !== 1001) { + console.warn( + `WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` + + `Attempting to reconnect...` + ); + scheduleReconnect(); + } else { + reconnectAttemptsRef.current = 0; + clearReconnectTimeout(); + } + }, + [scheduleReconnect, clearReconnectTimeout] + ); const onError = useCallback((error: Event) => { console.error("WebSocket error:", error); @@ -68,31 +103,110 @@ export function WebSocketGate() { [onMessage, onClose, onError] ); + useEffect(() => { + isAuthenticatedRef.current = isAuthenticated; + }, [isAuthenticated]); + + // Reset reconnection state on successful connection and mark for calendar re-sync + useEffect(() => { + if (isSocketOpen) { + console.log("WebSocket connected successfully"); + + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; + } + + if (hadSocketBeforeRef.current) { + justReconnectedRef.current = true; + } + + hadSocketBeforeRef.current = true; + reconnectAttemptsRef.current = 0; + + clearReconnectTimeout(); + } + }, [isSocketOpen, clearReconnectTimeout]); + // Manage WebSocket connection useEffect(() => { const abortController = new AbortController(); - if (!isAuthenticated) { + + const cleanup = () => { + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; + } closeWebSocketConnection(socketRef, setIsSocketOpen); + clearReconnectTimeout(); + }; + + if (!isAuthenticated) { + cleanup(); + reconnectAttemptsRef.current = 0; + + hadSocketBeforeRef.current = false; return; } - establishWebSocketConnection( - callBacks, - socketRef, - setIsSocketOpen, - abortController.signal - ); + const connect = async () => { + if (isConnectingRef.current || isSocketOpen) return; + isConnectingRef.current = true; + connectTimeoutRef.current = setTimeout(() => { + console.warn("WebSocket connection attempt timed out"); + + cleanup(); + + scheduleReconnect(); + }, CONNECT_TIMEOUT_MS); + + try { + await establishWebSocketConnection( + callBacks, + socketRef, + setIsSocketOpen, + abortController.signal + ); + } catch (err) { + console.warn("WebSocket establishment failed:", err); + + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; + } + + scheduleReconnect(); + } finally { + isConnectingRef.current = false; + } + }; + + connect(); return () => { abortController.abort(); - closeWebSocketConnection(socketRef, setIsSocketOpen); + cleanup(); }; - }, [isAuthenticated, callBacks]); + }, [ + isAuthenticated, + callBacks, + clearReconnectTimeout, + shouldConnect, + scheduleReconnect, + ]); // Register using a diff with previous calendars useEffect(() => { if (isPending) return; + // If we just reconnected, force a re-sync + if (justReconnectedRef.current && isSocketOpen) { + console.log("Re-syncing calendars after reconnection"); + previousCalendarListRef.current = []; + previousTempCalendarListRef.current = []; + justReconnectedRef.current = false; + } + syncCalendarRegistrations( isSocketOpen, socketRef, @@ -110,5 +224,41 @@ export function WebSocketGate() { ); }, [isSocketOpen, tempCalendarList]); + // Handle browser online/offline events + useEffect(() => { + const handleOnline = () => { + console.log("Browser is online, attempting WebSocket reconnection"); + if (!isSocketOpen && isAuthenticatedRef.current) { + reconnectAttemptsRef.current = 0; + clearReconnectTimeout(); + setShouldConnect((prev) => !prev); + } + }; + + const handleOffline = () => { + console.log( + "Browser is offline, pausing WebSocket reconnection attempts" + ); + cleanupConnection(); + }; + + const cleanupConnection = () => { + closeWebSocketConnection(socketRef, setIsSocketOpen); + clearReconnectTimeout(); + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; + } + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [isSocketOpen, isAuthenticated, clearReconnectTimeout]); + return null; } diff --git a/src/websocket/connection/lifecycle/useWebSocketReconnect.ts b/src/websocket/connection/lifecycle/useWebSocketReconnect.ts new file mode 100644 index 0000000..95aa4cd --- /dev/null +++ b/src/websocket/connection/lifecycle/useWebSocketReconnect.ts @@ -0,0 +1,56 @@ +import { getRetryDelay, RetryBackoffConfig } from "@/utils/getRetryDelay"; +import { Dispatch, MutableRefObject, SetStateAction, useCallback } from "react"; + +export const RECONNECT_CONFIG: RetryBackoffConfig = { + initialDelay: 1000, // 1 second + maxDelay: 30000, // 30 seconds +}; +export const MAX_RECONNECT_ATTEMPTS = 10; + +export function useWebSocketReconnect( + reconnectTimeoutRef: MutableRefObject, + isAuthenticatedRef: MutableRefObject, + reconnectAttemptsRef: MutableRefObject, + setShouldConnect: Dispatch> +) { + const clearReconnectTimeout = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }, []); + + const scheduleReconnect = useCallback(() => { + if (!isAuthenticatedRef.current) return; + + if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { + console.error( + `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.` + ); + return; + } + + clearReconnectTimeout(); + + const delay = getRetryDelay(reconnectAttemptsRef.current, RECONNECT_CONFIG); + reconnectAttemptsRef.current += 1; + + console.log( + `Scheduling WebSocket reconnection in ${Math.round(delay)}ms ` + + `(attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})` + ); + + reconnectTimeoutRef.current = setTimeout(() => { + if (!isAuthenticatedRef.current) { + reconnectTimeoutRef.current = null; + return; + } + console.log( + `Attempting WebSocket reconnection (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})` + ); + setShouldConnect((prev) => !prev); + clearReconnectTimeout(); + }, delay); + }, [clearReconnectTimeout]); + return { scheduleReconnect, clearReconnectTimeout }; +}