Skip to content

Commit 8bc5114

Browse files
author
Camille Moussu
committed
[#474 & #447] added ping to complete browser online/offline status
1 parent 203f1ed commit 8bc5114

File tree

6 files changed

+335
-16
lines changed

6 files changed

+335
-16
lines changed

__test__/features/websocket/WebSocketGate.test.tsx

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ describe("WebSocketGate", () => {
5656
const createMockSocket = (readyState = WebSocket.OPEN) => ({
5757
readyState,
5858
close: jest.fn(),
59+
send: jest.fn(),
5960
addEventListener: jest.fn(),
6061
removeEventListener: jest.fn(),
6162
cleanup: jest.fn(),
63+
onmessage: null,
6264
});
6365

6466
beforeEach(() => {
@@ -340,19 +342,11 @@ describe("WebSocketGate", () => {
340342
jest.advanceTimersByTime(1000);
341343
});
342344

343-
// Second failure
344-
await act(async () => {
345-
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
346-
jest.advanceTimersByTime(2000);
347-
});
348-
349-
// Success - should log and reset counter
350-
await waitFor(() => {
351-
expect(consoleLog).toHaveBeenCalledWith(
352-
"WebSocket connected successfully"
353-
);
354-
});
345+
expect(consoleLog).toHaveBeenCalledWith(
346+
expect.stringContaining("(attempt 1/10)")
347+
);
355348

349+
// Success - should reset counter
356350
// Next failure should start from attempt 1 again
357351
await act(async () => {
358352
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
@@ -906,4 +900,113 @@ describe("WebSocketGate", () => {
906900
expect(registerToCalendars).not.toHaveBeenCalled();
907901
});
908902
});
903+
904+
describe("Ping/Pong Integration", () => {
905+
let mockPingCleanup: { stop: jest.Mock; sendPing: jest.Mock };
906+
907+
beforeEach(() => {
908+
jest.useFakeTimers();
909+
mockPingCleanup = {
910+
stop: jest.fn(),
911+
sendPing: jest.fn(),
912+
};
913+
});
914+
915+
afterEach(() => {
916+
jest.runOnlyPendingTimers();
917+
jest.useRealTimers();
918+
});
919+
920+
it("should trigger reconnection when ping detects dead connection (via socket close)", async () => {
921+
const consoleWarn = jest.spyOn(console, "warn").mockImplementation();
922+
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
923+
924+
(createWebSocketConnection as jest.Mock).mockImplementation(
925+
(callbacks) => {
926+
onCloseCallback = callbacks.onClose;
927+
return Promise.resolve(mockSocket);
928+
}
929+
);
930+
931+
render(<TestWrapper store={store} />);
932+
933+
await waitFor(() => {
934+
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
935+
});
936+
937+
(createWebSocketConnection as jest.Mock).mockClear();
938+
939+
// In the real implementation, when ping detects dead connection,
940+
// it calls socket.close() which triggers the onClose callback
941+
// This simulates that flow
942+
await act(async () => {
943+
if (onCloseCallback) {
944+
onCloseCallback(new CloseEvent("close", { code: 1006 }));
945+
}
946+
});
947+
948+
// Should schedule reconnection
949+
expect(consoleWarn).toHaveBeenCalledWith(
950+
expect.stringContaining("WebSocket closed unexpectedly")
951+
);
952+
953+
// Advance to trigger reconnection
954+
await act(async () => {
955+
jest.advanceTimersByTime(1500);
956+
});
957+
958+
// Should reconnect
959+
await waitFor(() => {
960+
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
961+
});
962+
963+
consoleWarn.mockRestore();
964+
});
965+
966+
it("should stop ping monitoring when socket closes normally", async () => {
967+
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
968+
969+
(createWebSocketConnection as jest.Mock).mockImplementation(
970+
(callbacks) => {
971+
onCloseCallback = callbacks.onClose;
972+
return Promise.resolve(mockSocket);
973+
}
974+
);
975+
976+
render(<TestWrapper store={store} />);
977+
978+
await waitFor(() => {
979+
expect(createWebSocketConnection).toHaveBeenCalled();
980+
});
981+
982+
// Normal close (code 1000) - like logout or page navigation
983+
await act(async () => {
984+
if (onCloseCallback) {
985+
onCloseCallback(new CloseEvent("close", { code: 1000 }));
986+
}
987+
});
988+
989+
// Should not attempt reconnection
990+
await act(async () => {
991+
jest.advanceTimersByTime(5000);
992+
});
993+
994+
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
995+
});
996+
997+
it("should cleanup ping monitoring on component unmount", async () => {
998+
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
999+
1000+
const { unmount } = render(<TestWrapper store={store} />);
1001+
1002+
await waitFor(() => {
1003+
expect(createWebSocketConnection).toHaveBeenCalled();
1004+
});
1005+
1006+
// Unmount should cleanup
1007+
unmount();
1008+
1009+
expect(mockSocket.close).toHaveBeenCalled();
1010+
});
1011+
});
9091012
});

src/websocket/WebSocketGate.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconn
1010
import { updateCalendars } from "./messaging/updateCalendars";
1111
import { syncCalendarRegistrations } from "./operations";
1212
import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar";
13+
import {
14+
setupWebSocketPing,
15+
type PingCleanup,
16+
} from "./connection/lifecycle/pingWebSocket";
1317

1418
export function WebSocketGate() {
1519
const socketRef = useRef<WebSocketWithCleanup | null>(null);
@@ -18,6 +22,7 @@ export function WebSocketGate() {
1822
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
1923
const reconnectAttemptsRef = useRef(0);
2024
const isConnectingRef = useRef(false);
25+
const pingCleanupRef = useRef<PingCleanup | null>(null);
2126

2227
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2328
const CONNECT_TIMEOUT_MS = 10_000;
@@ -124,8 +129,6 @@ export function WebSocketGate() {
124129
// Reset reconnection state on successful connection and mark for calendar re-sync
125130
useEffect(() => {
126131
if (isSocketOpen) {
127-
console.log("WebSocket connected successfully");
128-
129132
if (connectTimeoutRef.current) {
130133
clearTimeout(connectTimeoutRef.current);
131134
connectTimeoutRef.current = null;
@@ -276,6 +279,44 @@ export function WebSocketGate() {
276279
};
277280
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
278281

282+
useEffect(() => {
283+
// Only set up ping if socket is open
284+
if (!isSocketOpen || !socketRef.current) {
285+
// Clean up existing ping if socket closed
286+
if (pingCleanupRef.current) {
287+
pingCleanupRef.current.stop();
288+
pingCleanupRef.current = null;
289+
}
290+
return;
291+
}
292+
293+
// Set up ping monitoring
294+
const pingCleanup = setupWebSocketPing(socketRef.current, {
295+
onConnectionDead: () => {
296+
console.warn("WebSocket connection appears dead (no pong received)");
297+
setWebSocketStatus(t("websocket.browserOffline"));
298+
setWebSocketStatusSerity("warning");
299+
300+
// Trigger reconnection
301+
if (socketRef.current) {
302+
socketRef.current.close();
303+
}
304+
},
305+
onPingFail: () => {
306+
console.warn("Failed to send ping");
307+
},
308+
});
309+
310+
pingCleanupRef.current = pingCleanup;
311+
312+
return () => {
313+
if (pingCleanupRef.current) {
314+
pingCleanupRef.current.stop();
315+
pingCleanupRef.current = null;
316+
}
317+
};
318+
}, [isSocketOpen]);
319+
279320
return websocketStatus ? (
280321
<WebSocketStatusSnackbar
281322
message={websocketStatus}

src/websocket/WebSocketStatusSnackbar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export function WebSocketStatusSnackbar({
1414
return (
1515
<Snackbar
1616
open={!!message}
17-
autoHideDuration={6000}
1817
onClose={onClose}
18+
autoHideDuration={
19+
severity === "warning" || severity === "error" ? null : 6000
20+
}
1921
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
2022
>
2123
<Alert

0 commit comments

Comments
 (0)