474 websocket add a snack bar to notify the user on websocket status changes (#483)
* [#474] added snackbar for websocket status * [#474 & #447] added ping to complete browser online/offline status * [#447] ping timeout as an env var
This commit is contained in:
@@ -3,13 +3,40 @@ import { createWebSocketConnection } from "@/websocket/connection/createConnecti
|
||||
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
|
||||
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
|
||||
import { WebSocketGate } from "@/websocket/WebSocketGate";
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { configureStore, Store } from "@reduxjs/toolkit";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { Provider } from "react-redux";
|
||||
import { I18nContext } from "twake-i18n";
|
||||
|
||||
jest.mock("@/websocket/connection/createConnection");
|
||||
jest.mock("@/websocket/operations/registerToCalendars");
|
||||
jest.mock("@/websocket/operations/unregisterToCalendars");
|
||||
jest.mock("@/websocket/connection/lifecycle/pingWebSocket");
|
||||
|
||||
function TestWrapper({ store }: { store: Store }) {
|
||||
return (
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
t: (key: string, vars?: Record<string, string>) => {
|
||||
if (key === "locale") return "en";
|
||||
if (vars) {
|
||||
const params = Object.entries(vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",");
|
||||
return `${key}(${params})`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
f: (date: Date) => date.toString(),
|
||||
lang: "en",
|
||||
}}
|
||||
>
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebSocketGate", () => {
|
||||
let store: any;
|
||||
@@ -30,9 +57,11 @@ describe("WebSocketGate", () => {
|
||||
const createMockSocket = (readyState = WebSocket.OPEN) => ({
|
||||
readyState,
|
||||
close: jest.fn(),
|
||||
send: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
cleanup: jest.fn(),
|
||||
onmessage: null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +73,7 @@ describe("WebSocketGate", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -61,11 +91,7 @@ describe("WebSocketGate", () => {
|
||||
it("should not create connection when user is not authenticated", () => {
|
||||
const unauthStore = createMockStore(null, null);
|
||||
|
||||
render(
|
||||
<Provider store={unauthStore}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={unauthStore} />);
|
||||
|
||||
expect(createWebSocketConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -73,11 +99,7 @@ describe("WebSocketGate", () => {
|
||||
it("should create connection when user is authenticated", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -87,22 +109,14 @@ describe("WebSocketGate", () => {
|
||||
it("should close existing socket when user logs out", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
const { rerender } = render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const unauthStore = createMockStore(null, null);
|
||||
rerender(
|
||||
<Provider store={unauthStore}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
rerender(<TestWrapper store={unauthStore} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
@@ -114,11 +128,7 @@ describe("WebSocketGate", () => {
|
||||
it("should create connection with callbacks", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledWith(
|
||||
@@ -141,11 +151,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
@@ -172,11 +178,7 @@ describe("WebSocketGate", () => {
|
||||
new Error("Connection failed")
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
@@ -191,11 +193,7 @@ describe("WebSocketGate", () => {
|
||||
it("should close socket on component unmount", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
const { unmount } = render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
const { unmount } = render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
@@ -230,11 +228,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -275,11 +269,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -311,11 +301,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -346,11 +332,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -362,19 +344,11 @@ describe("WebSocketGate", () => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
expect(consoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("(attempt 1/10)")
|
||||
);
|
||||
|
||||
// Success - should reset counter
|
||||
// Next failure should start from attempt 1 again
|
||||
await act(async () => {
|
||||
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
|
||||
@@ -400,11 +374,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
const { rerender } = render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -417,11 +387,7 @@ describe("WebSocketGate", () => {
|
||||
|
||||
// Lose authentication before timeout fires
|
||||
const unauthStore = createMockStore(null, null);
|
||||
rerender(
|
||||
<Provider store={unauthStore}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
rerender(<TestWrapper store={unauthStore} />);
|
||||
|
||||
// Advance timer
|
||||
await act(async () => {
|
||||
@@ -444,11 +410,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
const { unmount } = render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -487,11 +449,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||
@@ -540,11 +498,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -577,11 +531,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -609,11 +559,7 @@ describe("WebSocketGate", () => {
|
||||
it("should not reconnect when online event fires if already connected", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -640,11 +586,7 @@ describe("WebSocketGate", () => {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
@@ -693,11 +635,7 @@ describe("WebSocketGate", () => {
|
||||
connectingSocket
|
||||
);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
@@ -713,11 +651,7 @@ describe("WebSocketGate", () => {
|
||||
);
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||
@@ -731,11 +665,7 @@ describe("WebSocketGate", () => {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||
@@ -764,11 +694,7 @@ describe("WebSocketGate", () => {
|
||||
);
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalled();
|
||||
@@ -795,11 +721,7 @@ describe("WebSocketGate", () => {
|
||||
);
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalled();
|
||||
@@ -829,11 +751,7 @@ describe("WebSocketGate", () => {
|
||||
throw new Error("Registration failed");
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
@@ -868,11 +786,7 @@ describe("WebSocketGate", () => {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify([]));
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
@@ -888,11 +802,7 @@ describe("WebSocketGate", () => {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalledTimes(1);
|
||||
@@ -913,11 +823,7 @@ describe("WebSocketGate", () => {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalled();
|
||||
@@ -945,11 +851,7 @@ describe("WebSocketGate", () => {
|
||||
);
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerToCalendars).toHaveBeenCalled();
|
||||
@@ -979,11 +881,7 @@ describe("WebSocketGate", () => {
|
||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<WebSocketGate />
|
||||
</Provider>
|
||||
);
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
@@ -1004,4 +902,107 @@ describe("WebSocketGate", () => {
|
||||
expect(registerToCalendars).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ping/Pong Integration", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should trigger reconnection when ping detects dead connection (via socket close)", async () => {
|
||||
const consoleWarn = jest.spyOn(console, "warn").mockImplementation();
|
||||
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
|
||||
|
||||
(createWebSocketConnection as jest.Mock).mockImplementation(
|
||||
(callbacks) => {
|
||||
onCloseCallback = callbacks.onClose;
|
||||
return Promise.resolve(mockSocket);
|
||||
}
|
||||
);
|
||||
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
(createWebSocketConnection as jest.Mock).mockClear();
|
||||
|
||||
// In the real implementation, when ping detects dead connection,
|
||||
// it calls socket.close() which triggers the onClose callback
|
||||
// This simulates that flow
|
||||
await act(async () => {
|
||||
if (onCloseCallback) {
|
||||
onCloseCallback(new CloseEvent("close", { code: 1006 }));
|
||||
}
|
||||
});
|
||||
|
||||
// Should schedule reconnection
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("WebSocket closed unexpectedly")
|
||||
);
|
||||
|
||||
// Advance to trigger reconnection
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
// Should reconnect
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
|
||||
it("should stop ping monitoring when socket closes normally", async () => {
|
||||
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
|
||||
|
||||
(createWebSocketConnection as jest.Mock).mockImplementation(
|
||||
(callbacks) => {
|
||||
onCloseCallback = callbacks.onClose;
|
||||
return Promise.resolve(mockSocket);
|
||||
}
|
||||
);
|
||||
|
||||
render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Normal close (code 1000) - like logout or page navigation
|
||||
await act(async () => {
|
||||
if (onCloseCallback) {
|
||||
onCloseCallback(new CloseEvent("close", { code: 1000 }));
|
||||
}
|
||||
});
|
||||
|
||||
// Should not attempt reconnection
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cleanup ping monitoring on component unmount", async () => {
|
||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||
|
||||
const { unmount } = render(<TestWrapper store={store} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Unmount should cleanup
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,3 +12,5 @@ var DEBUG = false;
|
||||
var LANG = "en";
|
||||
var WEBSOCKET_URL = "wss://calendar.example.com";
|
||||
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
|
||||
var WS_PING_PERIOD_MS = 30000;
|
||||
var WS_PING_TIMEOUT_PERIOD_MS = 35000;
|
||||
|
||||
@@ -299,6 +299,13 @@
|
||||
"endDate": "End Date",
|
||||
"endTime": "End Time"
|
||||
},
|
||||
"websocket": {
|
||||
"browserOnline": "You’re back online. Reconnecting live updates…",
|
||||
"browserOffline": "You’re offline. Live updates are paused until the connection is restored.",
|
||||
"reconnected": "Live updates are back.",
|
||||
"error": "There was a problem with live updates: %{error}",
|
||||
"closedUnexpectedly": "Live updates were interrupted. Trying to reconnect…"
|
||||
},
|
||||
"months": {
|
||||
"standalone": {
|
||||
"0": "January",
|
||||
|
||||
@@ -300,6 +300,13 @@
|
||||
"endDate": "Date de la fin",
|
||||
"endTime": "Heure de la fin"
|
||||
},
|
||||
"websocket": {
|
||||
"browserOnline": "Vous êtes de nouveau en ligne. Reconnexion des mises à jour en temps réel…",
|
||||
"browserOffline": "Vous êtes hors ligne. Les mises à jour en temps réel sont mises en pause jusqu’au rétablissement de la connexion.",
|
||||
"reconnected": "Les mises à jour en temps réel sont de nouveau actives.",
|
||||
"error": "Un problème est survenu avec les mises à jour en temps réel : %{error}",
|
||||
"closedUnexpectedly": "Les mises à jour en temps réel ont été interrompues. Tentative de reconnexion…"
|
||||
},
|
||||
"months": {
|
||||
"standalone": {
|
||||
"0": "Janvier",
|
||||
|
||||
@@ -300,6 +300,13 @@
|
||||
"endDate": "Дата окончания",
|
||||
"endTime": "Время окончания"
|
||||
},
|
||||
"websocket": {
|
||||
"browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
|
||||
"browserOffline": "Вы не в сети. Обновления в реальном времени приостановлены до восстановления соединения.",
|
||||
"reconnected": "Обновления в реальном времени снова работают.",
|
||||
"error": "Произошла ошибка в обновлениях в реальном времени: %{error}",
|
||||
"closedUnexpectedly": "Обновления в реальном времени были прерваны. Пробуем переподключиться…"
|
||||
},
|
||||
"months": {
|
||||
"standalone": {
|
||||
"0": "Январь",
|
||||
|
||||
@@ -298,6 +298,13 @@
|
||||
"endDate": "Ngày kết thúc",
|
||||
"endTime": "Giờ kết thúc"
|
||||
},
|
||||
"websocket": {
|
||||
"browserOnline": "Bạn đã trực tuyến trở lại. Đang kết nối lại cập nhật thời gian thực…",
|
||||
"browserOffline": "Bạn đang ngoại tuyến. Cập nhật thời gian thực sẽ tạm dừng cho đến khi kết nối được khôi phục.",
|
||||
"reconnected": "Cập nhật thời gian thực đã hoạt động trở lại.",
|
||||
"error": "Đã xảy ra sự cố với cập nhật thời gian thực: %{error}",
|
||||
"closedUnexpectedly": "Cập nhật thời gian thực đã bị gián đoạn. Đang thử kết nối lại…"
|
||||
},
|
||||
"months": {
|
||||
"standalone": {
|
||||
"0": "Tháng 1",
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ class IntersectionObserverMock {
|
||||
}
|
||||
|
||||
(global as any).IntersectionObserver = IntersectionObserverMock;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).WS_PING_PERIOD_MS = 5000;
|
||||
(window as any).WS_PING_TIMEOUT_PERIOD_MS = 5000;
|
||||
}
|
||||
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
|
||||
if (typeof window !== "undefined" && window.getComputedStyle) {
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||
import { AppDispatch } from "@/app/store";
|
||||
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "twake-i18n";
|
||||
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";
|
||||
import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar";
|
||||
import {
|
||||
setupWebSocketPing,
|
||||
type PingCleanup,
|
||||
} from "./connection/lifecycle/pingWebSocket";
|
||||
|
||||
export function WebSocketGate() {
|
||||
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
||||
@@ -16,12 +22,19 @@ export function WebSocketGate() {
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const isConnectingRef = useRef(false);
|
||||
const pingCleanupRef = useRef<PingCleanup | null>(null);
|
||||
|
||||
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const CONNECT_TIMEOUT_MS = 10_000;
|
||||
|
||||
const hadSocketBeforeRef = useRef(false);
|
||||
const justReconnectedRef = useRef(false);
|
||||
const [websocketStatus, setWebSocketStatus] = useState("");
|
||||
const [websocketStatusSerity, setWebSocketStatusSerity] = useState<
|
||||
"success" | "info" | "warning" | "error" | undefined
|
||||
>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const isAuthenticated = useAppSelector((state) =>
|
||||
@@ -81,6 +94,8 @@ export function WebSocketGate() {
|
||||
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
|
||||
`Attempting to reconnect...`
|
||||
);
|
||||
setWebSocketStatus(t("websocket.closedUnexpectedly"));
|
||||
setWebSocketStatusSerity("warning");
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
@@ -92,6 +107,10 @@ export function WebSocketGate() {
|
||||
|
||||
const onError = useCallback((error: Event) => {
|
||||
console.error("WebSocket error:", error);
|
||||
const errorMessage =
|
||||
(error as ErrorEvent)?.message ?? error.type ?? "unknown";
|
||||
setWebSocketStatus(t("websocket.error", { error: errorMessage }));
|
||||
setWebSocketStatusSerity("error");
|
||||
}, []);
|
||||
|
||||
const callBacks = useMemo(
|
||||
@@ -110,8 +129,6 @@ export function WebSocketGate() {
|
||||
// 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;
|
||||
@@ -119,6 +136,8 @@ export function WebSocketGate() {
|
||||
|
||||
if (hadSocketBeforeRef.current) {
|
||||
justReconnectedRef.current = true;
|
||||
setWebSocketStatus(t("websocket.reconnected"));
|
||||
setWebSocketStatusSerity("success");
|
||||
}
|
||||
|
||||
hadSocketBeforeRef.current = true;
|
||||
@@ -227,7 +246,8 @@ export function WebSocketGate() {
|
||||
// Handle browser online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log("Browser is online, attempting WebSocket reconnection");
|
||||
setWebSocketStatus(t("websocket.browserOnline"));
|
||||
setWebSocketStatusSerity("success");
|
||||
if (!isSocketOpen && isAuthenticatedRef.current) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
clearReconnectTimeout();
|
||||
@@ -236,9 +256,8 @@ export function WebSocketGate() {
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log(
|
||||
"Browser is offline, pausing WebSocket reconnection attempts"
|
||||
);
|
||||
setWebSocketStatus(t("websocket.browserOffline"));
|
||||
setWebSocketStatusSerity("warning");
|
||||
cleanupConnection();
|
||||
};
|
||||
|
||||
@@ -260,5 +279,52 @@ export function WebSocketGate() {
|
||||
};
|
||||
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
|
||||
|
||||
return null;
|
||||
useEffect(() => {
|
||||
// Only set up ping if socket is open
|
||||
if (!isSocketOpen || !socketRef.current) {
|
||||
// Clean up existing ping if socket closed
|
||||
if (pingCleanupRef.current) {
|
||||
pingCleanupRef.current.stop();
|
||||
pingCleanupRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up ping monitoring
|
||||
const pingCleanup = setupWebSocketPing(socketRef.current, {
|
||||
onConnectionDead: () => {
|
||||
console.warn("WebSocket connection appears dead (no pong received)");
|
||||
setWebSocketStatus(t("websocket.browserOffline"));
|
||||
setWebSocketStatusSerity("warning");
|
||||
|
||||
// Trigger reconnection
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
},
|
||||
onPingFail: () => {
|
||||
console.warn("Failed to send ping");
|
||||
},
|
||||
});
|
||||
|
||||
pingCleanupRef.current = pingCleanup;
|
||||
|
||||
return () => {
|
||||
if (pingCleanupRef.current) {
|
||||
pingCleanupRef.current.stop();
|
||||
pingCleanupRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSocketOpen]);
|
||||
|
||||
return websocketStatus ? (
|
||||
<WebSocketStatusSnackbar
|
||||
message={websocketStatus}
|
||||
severity={websocketStatusSerity}
|
||||
onClose={() => {
|
||||
setWebSocketStatus("");
|
||||
setWebSocketStatusSerity(undefined);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
|
||||
import { useI18n } from "twake-i18n";
|
||||
|
||||
export function WebSocketStatusSnackbar({
|
||||
message,
|
||||
severity,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
severity: "success" | "info" | "warning" | "error" | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Snackbar
|
||||
open={!!message}
|
||||
onClose={onClose}
|
||||
autoHideDuration={
|
||||
severity === "warning" || severity === "error" ? null : 6000
|
||||
}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Alert
|
||||
severity={severity}
|
||||
onClose={onClose}
|
||||
sx={{ width: "100%" }}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={onClose}>
|
||||
{t("common.ok")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { WebSocketWithCleanup } from "../types";
|
||||
|
||||
export interface PingConfig {
|
||||
/** Interval between ping attempts in milliseconds */
|
||||
pingInterval?: number;
|
||||
/** Timeout for pong response in milliseconds */
|
||||
pongTimeout?: number;
|
||||
/** Callback when connection is deemed dead */
|
||||
onConnectionDead?: () => void;
|
||||
/** Callback when ping fails */
|
||||
onPingFail?: () => void;
|
||||
/** Callback when pong is received successfully */
|
||||
onPongReceived?: () => void;
|
||||
}
|
||||
|
||||
export interface PingCleanup {
|
||||
stop: () => void;
|
||||
/** Force send a ping immediately */
|
||||
sendPing: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PING_INTERVAL = (window as any).WS_PING_PERIOD_MS ?? 30000;
|
||||
const DEFAULT_PONG_TIMEOUT = (window as any).WS_PING_TIMEOUT_PERIOD_MS ?? 35000;
|
||||
|
||||
/**
|
||||
* Sets up a ping/pong mechanism to monitor WebSocket connection health
|
||||
*
|
||||
* @param socket - The WebSocket connection to monitor
|
||||
* @param config - Configuration options for ping behavior
|
||||
* @returns Cleanup object with stop() and sendPing() methods
|
||||
*
|
||||
* @example
|
||||
* const pingCleanup = setupWebSocketPing(socket, {
|
||||
* pingInterval: 30000,
|
||||
* pongTimeout: 5000,
|
||||
* onConnectionDead: () => {
|
||||
* console.log('Connection is dead, reconnecting...');
|
||||
* reconnect();
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // Later, when closing the connection:
|
||||
* pingCleanup.stop();
|
||||
*/
|
||||
export function setupWebSocketPing(
|
||||
socket: WebSocketWithCleanup | null,
|
||||
config: PingConfig = {}
|
||||
): PingCleanup {
|
||||
const {
|
||||
pingInterval = DEFAULT_PING_INTERVAL,
|
||||
pongTimeout = DEFAULT_PONG_TIMEOUT,
|
||||
onConnectionDead,
|
||||
onPingFail,
|
||||
onPongReceived,
|
||||
} = config;
|
||||
|
||||
let pingIntervalId: NodeJS.Timeout | null = null;
|
||||
let pongTimeoutId: NodeJS.Timeout | null = null;
|
||||
let isWaitingForPong = false;
|
||||
let isStopped = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (pingIntervalId) {
|
||||
clearInterval(pingIntervalId);
|
||||
pingIntervalId = null;
|
||||
}
|
||||
if (pongTimeoutId) {
|
||||
clearTimeout(pongTimeoutId);
|
||||
pongTimeoutId = null;
|
||||
}
|
||||
isWaitingForPong = false;
|
||||
};
|
||||
|
||||
const sendPing = () => {
|
||||
if (isStopped || !socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're still waiting for a previous pong, connection might be dead
|
||||
if (isWaitingForPong) {
|
||||
console.warn(
|
||||
"Pong not received for previous ping. Connection may be dead."
|
||||
);
|
||||
onPingFail?.();
|
||||
onConnectionDead?.();
|
||||
isStopped = true;
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
|
||||
|
||||
isWaitingForPong = true;
|
||||
|
||||
// Set timeout for pong response
|
||||
pongTimeoutId = setTimeout(() => {
|
||||
if (isWaitingForPong) {
|
||||
console.warn("Pong timeout exceeded. Connection may be dead.");
|
||||
onPingFail?.();
|
||||
onConnectionDead?.();
|
||||
cleanup();
|
||||
}
|
||||
}, pongTimeout);
|
||||
} catch (error) {
|
||||
console.error("Failed to send ping:", error);
|
||||
onPingFail?.();
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePong = () => {
|
||||
if (pongTimeoutId) {
|
||||
clearTimeout(pongTimeoutId);
|
||||
pongTimeoutId = null;
|
||||
}
|
||||
isWaitingForPong = false;
|
||||
onPongReceived?.();
|
||||
};
|
||||
|
||||
// Start ping interval
|
||||
const startPinging = () => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
console.warn("Cannot start pinging: socket is not open");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send first ping immediately
|
||||
sendPing();
|
||||
|
||||
// Set up recurring pings
|
||||
pingIntervalId = setInterval(() => {
|
||||
sendPing();
|
||||
}, pingInterval);
|
||||
};
|
||||
|
||||
// Set up message listener for pong responses
|
||||
const originalOnMessage = socket?.onmessage;
|
||||
if (socket) {
|
||||
socket.onmessage = (event) => {
|
||||
let isPong = false;
|
||||
if (typeof event.data === "string") {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
// Check if it's an empty object {}
|
||||
isPong =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
Object.keys(payload).length === 0;
|
||||
} catch {
|
||||
// Non-JSON payload
|
||||
}
|
||||
}
|
||||
if (isPong) {
|
||||
handlePong();
|
||||
}
|
||||
// Call original handler
|
||||
originalOnMessage?.call(socket, event);
|
||||
};
|
||||
}
|
||||
|
||||
// Start the ping mechanism
|
||||
startPinging();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
isStopped = true;
|
||||
cleanup();
|
||||
// Restore original onmessage handler
|
||||
if (socket) {
|
||||
socket.onmessage = originalOnMessage ?? null;
|
||||
}
|
||||
},
|
||||
sendPing: () => {
|
||||
if (!isStopped) {
|
||||
sendPing();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WS_INBOUND_EVENTS } from "../protocols";
|
||||
|
||||
export function parseMessage(message: unknown) {
|
||||
console.log("WebSocket message received:", message);
|
||||
const calendarsToRefresh = new Set<string>();
|
||||
const calendarsToHide = new Set<string>();
|
||||
if (typeof message !== "object" || message === null) {
|
||||
@@ -20,6 +19,8 @@ export function parseMessage(message: unknown) {
|
||||
value.forEach((cal: string) => calendarsToHide.add(cal));
|
||||
}
|
||||
break;
|
||||
case WS_INBOUND_EVENTS.CALENDAR_CLIENT_REGISTERED:
|
||||
break;
|
||||
default: {
|
||||
calendarsToRefresh.add(key);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export const WS_INBOUND_EVENTS = {
|
||||
CONNECTION_CLOSED: "close",
|
||||
CLIENT_REGISTERED: "registered",
|
||||
CLIENT_UNREGISTERED: "unregistered",
|
||||
CALENDAR_CLIENT_REGISTERED: "calendarListRegistered",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user