* [#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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user