diff --git a/src/utils/socket-utils.ts b/src/utils/socket-utils.ts index 2f731311..7a657510 100644 --- a/src/utils/socket-utils.ts +++ b/src/utils/socket-utils.ts @@ -138,7 +138,7 @@ export function RoomsSocket({ config }: { config: RoomsSocketConfig }) { } function getListeners(room: string) { - return roomsToListeners[room]; + return roomsToListeners[room] ?? []; } const subscribeToRoom = ( @@ -151,13 +151,20 @@ export function RoomsSocket({ config }: { config: RoomsSocketConfig }) { } roomsToListeners[room].push(handlers); + let unsubscribed = false; return () => { + if (unsubscribed) { + return; + } + + unsubscribed = true; roomsToListeners[room] = roomsToListeners[room]?.filter((listener) => listener !== handlers) ?? []; if (roomsToListeners[room].length === 0) { leaveRoom(room); + delete roomsToListeners[room]; } }; }; diff --git a/tests/unit/socket-utils.test.ts b/tests/unit/socket-utils.test.ts new file mode 100644 index 00000000..035f3789 --- /dev/null +++ b/tests/unit/socket-utils.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { RoomsSocket } from "../../src/utils/socket-utils.ts"; + +const socketMock = vi.hoisted(() => ({ + disconnect: vi.fn(), + emit: vi.fn(), + listeners: {} as Record void>, + on: vi.fn(), +})); + +vi.mock("socket.io-client", () => ({ + io: vi.fn(() => ({ + id: "socket-id", + disconnect: socketMock.disconnect, + emit: socketMock.emit, + on: socketMock.on.mockImplementation((event: string, handler: any) => { + socketMock.listeners[event] = handler; + }), + })), +})); + +describe("RoomsSocket", () => { + beforeEach(() => { + socketMock.disconnect.mockClear(); + socketMock.emit.mockClear(); + socketMock.on.mockClear(); + socketMock.listeners = {}; + }); + + function createRoomsSocket() { + return RoomsSocket({ + config: { + serverUrl: "https://api.base44.test", + mountPath: "/socket.io/", + transports: ["websocket"], + appId: "test-app-id", + token: "test-token", + }, + }); + } + + test("shares one room join across multiple listeners until the last unsubscribe", () => { + const socket = createRoomsSocket(); + const firstUnsubscribe = socket.subscribeToRoom("room-a", {}); + const secondUnsubscribe = socket.subscribeToRoom("room-a", {}); + + expect(socketMock.emit).toHaveBeenCalledTimes(1); + expect(socketMock.emit).toHaveBeenCalledWith("join", "room-a"); + + firstUnsubscribe(); + + expect(socketMock.emit).toHaveBeenCalledTimes(1); + + secondUnsubscribe(); + + expect(socketMock.emit).toHaveBeenCalledTimes(2); + expect(socketMock.emit).toHaveBeenLastCalledWith("leave", "room-a"); + }); + + test("rejoins a room after its last listener unsubscribes", () => { + const socket = createRoomsSocket(); + const firstUnsubscribe = socket.subscribeToRoom("room-a", {}); + + firstUnsubscribe(); + socket.subscribeToRoom("room-a", {}); + + expect(socketMock.emit).toHaveBeenNthCalledWith(1, "join", "room-a"); + expect(socketMock.emit).toHaveBeenNthCalledWith(2, "leave", "room-a"); + expect(socketMock.emit).toHaveBeenNthCalledWith(3, "join", "room-a"); + }); + + test("unsubscribe is idempotent after the room is left", () => { + const socket = createRoomsSocket(); + const unsubscribe = socket.subscribeToRoom("room-a", {}); + + unsubscribe(); + unsubscribe(); + + expect(socketMock.emit).toHaveBeenCalledTimes(2); + expect(socketMock.emit).toHaveBeenNthCalledWith(1, "join", "room-a"); + expect(socketMock.emit).toHaveBeenNthCalledWith(2, "leave", "room-a"); + }); +});