[#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>
This commit is contained in:
Camille Moussu
2026-01-27 14:54:03 +01:00
committed by GitHub
parent d966a9eb2f
commit 83fae6f1c2
6 changed files with 1007 additions and 24 deletions
@@ -48,6 +48,16 @@ describe("WebSocketGate", () => {
}); });
describe("Authentication", () => { describe("Authentication", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
jest.resetModules();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it("should not create connection when user is not authenticated", () => { it("should not create connection when user is not authenticated", () => {
const unauthStore = createMockStore(null, null); 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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
// 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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
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", () => { describe("Calendar Registration", () => {
it("should not register if socket is not open", async () => { it("should not register if socket is not open", async () => {
localStorage.setItem( localStorage.setItem(
@@ -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<NodeJS.Timeout | null>;
let isAuthenticatedRef: MutableRefObject<boolean>;
let reconnectAttemptsRef: MutableRefObject<number>;
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();
});
});
+6 -9
View File
@@ -1,25 +1,22 @@
import { Auth } from "@/features/User/oidcAuth"; import { Auth } from "@/features/User/oidcAuth";
import ky from "ky"; import ky from "ky";
import { getRetryDelay } from "./getRetryDelay";
const RETRY_CONFIG = { const RETRY_CONFIG = {
maxRetries: 10, maxRetries: 10,
initialDelay: 1000, initialDelay: 1000,
maxDelay: 120000, 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({ export const api = ky.extend({
prefixUrl: (window as any).CALENDAR_BASE_URL, prefixUrl: (window as any).CALENDAR_BASE_URL,
retry: { retry: {
limit: RETRY_CONFIG.maxRetries, limit: RETRY_CONFIG.maxRetries,
backoffLimit: RETRY_CONFIG.maxDelay, backoffLimit: RETRY_CONFIG.maxDelay,
delay: (attemptCount) => getRetryDelay(attemptCount - 1), delay: (attemptCount) =>
getRetryDelay(attemptCount - 1, {
initialDelay: RETRY_CONFIG.initialDelay,
maxDelay: RETRY_CONFIG.maxDelay,
}),
}, },
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
+13
View File
@@ -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);
}
+165 -15
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WebSocketWithCleanup } from "./connection"; import type { WebSocketWithCleanup } from "./connection";
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection"; import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection"; import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect";
import { updateCalendars } from "./messaging/updateCalendars"; import { updateCalendars } from "./messaging/updateCalendars";
import { syncCalendarRegistrations } from "./operations"; import { syncCalendarRegistrations } from "./operations";
@@ -12,13 +13,25 @@ export function WebSocketGate() {
const socketRef = useRef<WebSocketWithCleanup | null>(null); const socketRef = useRef<WebSocketWithCleanup | null>(null);
const previousCalendarListRef = useRef<string[]>([]); const previousCalendarListRef = useRef<string[]>([]);
const previousTempCalendarListRef = useRef<string[]>([]); const previousTempCalendarListRef = useRef<string[]>([]);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const CONNECT_TIMEOUT_MS = 10_000;
const hadSocketBeforeRef = useRef(false);
const justReconnectedRef = useRef(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((state) => const isAuthenticated = useAppSelector((state) =>
Boolean(state.user.userData && state.user.tokens) Boolean(state.user.userData && state.user.tokens)
); );
const isAuthenticatedRef = useRef(isAuthenticated);
const [isSocketOpen, setIsSocketOpen] = useState(false); const [isSocketOpen, setIsSocketOpen] = useState(false);
const isPending = useAppSelector((state) => state.calendars.pending); const isPending = useAppSelector((state) => state.calendars.pending);
const [shouldConnect, setShouldConnect] = useState(false);
const calendarList = useSelectedCalendars(); const calendarList = useSelectedCalendars();
const tempCalendarList = Object.keys( const tempCalendarList = Object.keys(
@@ -48,12 +61,34 @@ export function WebSocketGate() {
[dispatch] [dispatch]
); );
const onClose = useCallback((event: CloseEvent) => { const { scheduleReconnect, clearReconnectTimeout } = useWebSocketReconnect(
// Socket already cleaned up by internal handler before this callback fires reconnectTimeoutRef,
socketRef.current = null; isAuthenticatedRef,
setIsSocketOpen(false); reconnectAttemptsRef,
// TODO: Add reconnection logic here 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) => { const onError = useCallback((error: Event) => {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
@@ -68,31 +103,110 @@ export function WebSocketGate() {
[onMessage, onClose, onError] [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 // Manage WebSocket connection
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
if (!isAuthenticated) {
const cleanup = () => {
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
}
closeWebSocketConnection(socketRef, setIsSocketOpen); closeWebSocketConnection(socketRef, setIsSocketOpen);
clearReconnectTimeout();
};
if (!isAuthenticated) {
cleanup();
reconnectAttemptsRef.current = 0;
hadSocketBeforeRef.current = false;
return; return;
} }
establishWebSocketConnection( const connect = async () => {
callBacks, if (isConnectingRef.current || isSocketOpen) return;
socketRef, isConnectingRef.current = true;
setIsSocketOpen, connectTimeoutRef.current = setTimeout(() => {
abortController.signal 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 () => { return () => {
abortController.abort(); abortController.abort();
closeWebSocketConnection(socketRef, setIsSocketOpen); cleanup();
}; };
}, [isAuthenticated, callBacks]); }, [
isAuthenticated,
callBacks,
clearReconnectTimeout,
shouldConnect,
scheduleReconnect,
]);
// Register using a diff with previous calendars // Register using a diff with previous calendars
useEffect(() => { useEffect(() => {
if (isPending) return; 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( syncCalendarRegistrations(
isSocketOpen, isSocketOpen,
socketRef, socketRef,
@@ -110,5 +224,41 @@ export function WebSocketGate() {
); );
}, [isSocketOpen, tempCalendarList]); }, [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; return null;
} }
@@ -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<NodeJS.Timeout | null>,
isAuthenticatedRef: MutableRefObject<boolean>,
reconnectAttemptsRef: MutableRefObject<number>,
setShouldConnect: Dispatch<SetStateAction<boolean>>
) {
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 };
}