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 };
+}