diff --git a/__test__/features/websocket/utils/notificationStorm.test.tsx b/__test__/features/websocket/utils/notificationStorm.test.tsx new file mode 100644 index 0000000..be2160d --- /dev/null +++ b/__test__/features/websocket/utils/notificationStorm.test.tsx @@ -0,0 +1,131 @@ +import type { AppDispatch, RootState } from "@/app/store"; +import { store } from "@/app/store"; +import { refreshCalendarWithSyncToken } from "@/features/Calendars/services"; +import { getDisplayedCalendarRange } from "@/utils"; +import { updateCalendars } from "@/websocket/messaging/updateCalendars"; + +jest.mock("@/features/Calendars/services", () => ({ + refreshCalendarWithSyncToken: jest.fn(), +})); + +jest.mock("@/utils", () => ({ + getDisplayedCalendarRange: jest.fn(), + findCalendarById: jest.requireActual("@/utils").findCalendarById, +})); + +jest.mock("@/app/store", () => ({ + store: { + getState: jest.fn(), + }, +})); + +jest.useFakeTimers(); +const mockDispatch = jest.fn(); +const mockRange = { + start: new Date("2025-01-15T10:00:00Z"), + end: new Date("2025-01-16T10:00:00Z"), +}; +const mockState = { + calendars: { + list: { + "cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, + "cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, + "cal/A": { id: "cal/A", name: "Cal A", syncToken: 1 }, + "cal/B": { id: "cal/B", name: "Cal B", syncToken: 1 }, + "cal/C": { id: "cal/C", name: "Cal C", syncToken: 1 }, + }, + templist: {}, + }, +} as unknown as RootState; +const mockAccumulators: { + calendarsToRefresh: Map; + calendarsToHide: Set; + debouncedUpdateFn?: (dispatch: AppDispatch) => void; + currentDebouncePeriod?: number; +} = { + calendarsToRefresh: new Map(), + calendarsToHide: new Set(), + currentDebouncePeriod: 0, + debouncedUpdateFn: undefined, +}; + +describe("websocket messages storm", () => { + beforeEach(() => { + (refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear(); + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetModules(); + (getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); + (store.getState as jest.Mock).mockReturnValue(mockState); + (window as any).WS_DEBOUNCE_PERIOD_MS = 500; + mockAccumulators.calendarsToRefresh = new Map(); + mockAccumulators.calendarsToHide = new Set(); + mockAccumulators.currentDebouncePeriod = 0; + mockAccumulators.debouncedUpdateFn = undefined; + }); + it("debounces calendar updates during message storm", () => { + const mockMessage = { + "/calendars/cal1/entry1": { + syncToken: "ldsk", + }, + }; + + for (let i = 0; i < 50; i++) { + updateCalendars(mockMessage, mockDispatch, mockAccumulators); + } + + // Dispatch called once because of leading edge + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); + + // Trailing edge + jest.advanceTimersByTime(500); + + // only one call for the last message + leading edge message + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2); + }); + + it("debounces calendar updates during message storm with multiple updates", () => { + // Send a storm with mixed messages + for (let i = 0; i < 50; i++) { + if (i % 3 === 0) + updateCalendars( + { "/calendars/cal/A": { syncToken: "ldskfjsld" + i } }, + mockDispatch, + mockAccumulators + ); + else if (i % 3 === 1) + updateCalendars( + { "/calendars/cal/B": { syncToken: "ldskfjsld" + i } }, + mockDispatch, + mockAccumulators + ); + else + updateCalendars( + { "/calendars/cal/C": { syncToken: "ldskfjsld" + i } }, + mockDispatch, + mockAccumulators + ); + } + + // Dispatch called once because of leading edge + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); + + // Trailing edge + jest.advanceTimersByTime(500); + + // Trailing edge updates once per calendar + the original leading edge + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(4); + }); + + it("executes immediately when debounce is disabled", () => { + (window as any).WS_DEBOUNCE_PERIOD_MS = 0; + + updateCalendars( + { "/calendars/cal1/entry1": { syncToken: "abc" } }, + mockDispatch, + mockAccumulators + ); + + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__test__/features/websocket/utils/updateCalendars.test.tsx b/__test__/features/websocket/utils/updateCalendars.test.tsx index 4ede774..fea93b8 100644 --- a/__test__/features/websocket/utils/updateCalendars.test.tsx +++ b/__test__/features/websocket/utils/updateCalendars.test.tsx @@ -1,4 +1,4 @@ -import { RootState, store } from "@/app/store"; +import { AppDispatch, RootState, store } from "@/app/store"; import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar"; import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager"; import { updateCalendars } from "@/websocket/messaging/updateCalendars"; @@ -29,23 +29,38 @@ describe("updateCalendars", () => { templist: {}, }, } as unknown as RootState; + const mockAccumulators: { + calendarsToRefresh: Map; + calendarsToHide: Set; + debouncedUpdateFn?: (dispatch: AppDispatch) => void; + currentDebouncePeriod?: number; + } = { + calendarsToRefresh: new Map(), + calendarsToHide: new Set(), + currentDebouncePeriod: 0, + debouncedUpdateFn: jest.fn(), + }; beforeEach(() => { jest.clearAllMocks(); mockDispatch = jest.fn(); (getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); (store.getState as jest.Mock).mockReturnValue(mockState); + mockAccumulators.calendarsToRefresh = new Map(); + mockAccumulators.calendarsToHide = new Set(); + mockAccumulators.currentDebouncePeriod = 0; + mockAccumulators.debouncedUpdateFn = jest.fn(); }); it("should not dispatch for non-object messages", () => { - updateCalendars(null, mockDispatch); - updateCalendars("string", mockDispatch); - updateCalendars(123, mockDispatch); + updateCalendars(null, mockDispatch, mockAccumulators); + updateCalendars("string", mockDispatch, mockAccumulators); + updateCalendars(123, mockDispatch, mockAccumulators); expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); }); - it("should dispatch for registered calendars", () => { + it("should dispatch for registered calendars", async () => { const message = { [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ "/calendars/cal1/entry1", @@ -53,19 +68,22 @@ describe("updateCalendars", () => { ], }; - updateCalendars(message, mockDispatch); + updateCalendars(message, mockDispatch, mockAccumulators); - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2); + await waitFor(() => + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2) + ); }); - it("should dispatch for calendar path updates", () => { + it("should dispatch for calendar path updates", async () => { const message = { "/calendars/cal1/entry1": { updated: true }, }; - updateCalendars(message, mockDispatch); - - expect(refreshCalendarWithSyncToken).toHaveBeenCalled(); + updateCalendars(message, mockDispatch, mockAccumulators); + await waitFor(() => + expect(refreshCalendarWithSyncToken).toHaveBeenCalled() + ); expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ calendar: mockState.calendars.list["cal1/entry1"], calType: undefined, @@ -73,14 +91,13 @@ describe("updateCalendars", () => { }); }); - it("should use current displayed calendar range", () => { + it("should use displayed calendar range", async () => { const message = { "/calendars/cal1/entry1": {}, }; - updateCalendars(message, mockDispatch); - - expect(getDisplayedCalendarRange).toHaveBeenCalled(); + updateCalendars(message, mockDispatch, mockAccumulators); + await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled()); }); it("should handle temp calendars", async () => { @@ -103,7 +120,7 @@ describe("updateCalendars", () => { "/calendars/temp1/entry1": {}, }; - updateCalendars(message, mockDispatch); + updateCalendars(message, mockDispatch, mockAccumulators); await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ @@ -120,7 +137,7 @@ describe("updateCalendars", () => { "not-a-path": {}, }; - updateCalendars(message, mockDispatch); + updateCalendars(message, mockDispatch, mockAccumulators); expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); }); diff --git a/package-lock.json b/package-lock.json index c9f8c45..46dc3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "i18next": "^23.7.9", "ical.js": "^2.2.0", "ky": "^1.8.1", + "lodash": "^4.17.23", "moment": "^2.30.1", "moment-timezone": "^0.5.48", "openid-client": "^6.5.3", @@ -53,6 +54,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.24", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", @@ -4619,6 +4621,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -11103,9 +11112,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/package.json b/package.json index 9b24b6f..854e97c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "i18next": "^23.7.9", "ical.js": "^2.2.0", "ky": "^1.8.1", + "lodash": "^4.17.23", "moment": "^2.30.1", "moment-timezone": "^0.5.48", "openid-client": "^6.5.3", @@ -71,6 +72,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.24", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", diff --git a/public/.env.example.js b/public/.env.example.js index 405104b..1cea3e8 100644 --- a/public/.env.example.js +++ b/public/.env.example.js @@ -11,3 +11,4 @@ var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com"; 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 diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx index cb73e00..483fffb 100644 --- a/src/websocket/WebSocketGate.tsx +++ b/src/websocket/WebSocketGate.tsx @@ -1,10 +1,11 @@ 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 type { WebSocketWithCleanup } from "./connection"; import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection"; import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection"; -import { updateCalendars } from "./messaging"; +import { updateCalendars } from "./messaging/updateCalendars"; import { syncCalendarRegistrations } from "./operations"; export function WebSocketGate() { @@ -24,9 +25,25 @@ export function WebSocketGate() { useAppSelector((state) => state?.calendars?.templist) ?? {} ); + const calendarsToRefreshRef = useRef>(new Map()); + const calendarsToHideRef = useRef>(new Set()); + const debouncedUpdateFnRef = useRef< + ((dispatch: AppDispatch) => void) | undefined + >(); + const currentDebouncePeriodRef = useRef(); + const onMessage = useCallback( (message: unknown) => { - updateCalendars(message, dispatch); + const accumulators = { + calendarsToRefresh: calendarsToRefreshRef.current, + calendarsToHide: calendarsToHideRef.current, + debouncedUpdateFn: debouncedUpdateFnRef.current, + currentDebouncePeriod: currentDebouncePeriodRef.current, + }; + updateCalendars(message, dispatch, accumulators); + // Persist any mutations back to refs + debouncedUpdateFnRef.current = accumulators.debouncedUpdateFn; + currentDebouncePeriodRef.current = accumulators.currentDebouncePeriod; }, [dispatch] ); diff --git a/src/websocket/messaging/type/UpdateCalendarsAccumulators.ts b/src/websocket/messaging/type/UpdateCalendarsAccumulators.ts new file mode 100644 index 0000000..d6734d8 --- /dev/null +++ b/src/websocket/messaging/type/UpdateCalendarsAccumulators.ts @@ -0,0 +1,8 @@ +import { AppDispatch } from "@/app/store"; + +export interface UpdateCalendarsAccumulators { + calendarsToRefresh: Map; + calendarsToHide: Set; + debouncedUpdateFn?: (dispatch: AppDispatch) => void; + currentDebouncePeriod?: number; +} diff --git a/src/websocket/messaging/updateCalendars.ts b/src/websocket/messaging/updateCalendars.ts index 0e28e31..7927633 100644 --- a/src/websocket/messaging/updateCalendars.ts +++ b/src/websocket/messaging/updateCalendars.ts @@ -3,42 +3,157 @@ import { store } from "@/app/store"; import { refreshCalendarWithSyncToken } from "@/features/Calendars/services"; import { findCalendarById, getDisplayedCalendarRange } from "@/utils"; import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars"; +import { debounce } from "lodash"; import { parseCalendarPath } from "./parseCalendarPath"; import { parseMessage } from "./parseMessage"; +import { UpdateCalendarsAccumulators } from "./type/UpdateCalendarsAccumulators"; -export function updateCalendars(message: unknown, dispatch: AppDispatch) { - const currentRange = getDisplayedCalendarRange(); +const DEFAULT_DEBOUNCE_MS = 0; + +function createDebouncedUpdate( + debouncePeriodMs: number, + getCalendarsToRefresh: () => Map, + getCalendarsToHide: () => Set +) { + return debounce( + (dispatch: AppDispatch) => { + const currentRange = getDisplayedCalendarRange(); + + // Snapshot state + const calendarsToProcess = new Map(getCalendarsToRefresh()); + const calendarsToHideSnapshot = new Set(getCalendarsToHide()); + + // Clear accumulators + getCalendarsToRefresh().clear(); + getCalendarsToHide().clear(); + + try { + processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess); + processCalendarsToHide(calendarsToHideSnapshot); + } catch (error) { + console.warn("Error processing accumulated calendar updates:", error); + } + }, + debouncePeriodMs, + { leading: true, trailing: true } + ); +} + +export function updateCalendars( + message: unknown, + dispatch: AppDispatch, + accumulators: UpdateCalendarsAccumulators +) { const state = store.getState(); const { calendarsToRefresh, calendarsToHide } = parseMessage(message); - calendarsToRefresh.forEach((calendarPath) => { + + // Accumulate + accumulateCalendarsToRefresh( + state, + calendarsToRefresh, + accumulators.calendarsToRefresh + ); + accumulateCalendarsToHide(calendarsToHide, accumulators.calendarsToHide); + + const debouncePeriod = + (window as any).WS_DEBOUNCE_PERIOD_MS ?? DEFAULT_DEBOUNCE_MS; + + if (debouncePeriod > 0) { + if ( + !accumulators.debouncedUpdateFn || + accumulators.currentDebouncePeriod !== debouncePeriod + ) { + accumulators.debouncedUpdateFn = createDebouncedUpdate( + debouncePeriod, + () => accumulators.calendarsToRefresh, + () => accumulators.calendarsToHide + ); + accumulators.currentDebouncePeriod = debouncePeriod; + } + accumulators.debouncedUpdateFn(dispatch); + return; + } + + // Immediate processing if debounce disabled + const currentRange = getDisplayedCalendarRange(); + const calendarsToProcess = new Map(accumulators.calendarsToRefresh); + const calendarsToHideSnapshot = new Set(accumulators.calendarsToHide); + + accumulators.calendarsToRefresh.clear(); + accumulators.calendarsToHide.clear(); + + try { + processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess); + processCalendarsToHide(calendarsToHideSnapshot); + } catch (error) { + console.warn("Error processing calendar updates:", error); + } +} + +// --- Helpers --- +function accumulateCalendarsToRefresh( + state: ReturnType, + calendarPaths: Set, + calendarsToRefreshMap: Map +) { + calendarPaths.forEach((calendarPath) => { const calendarId = parseCalendarPath(calendarPath); if (!calendarId) { console.warn("Invalid calendar path received:", calendarPath); return; } const calendar = findCalendarById(state, calendarId); - if (calendar) { - dispatch( - refreshCalendarWithSyncToken({ - calendar: calendar.calendar, - calType: calendar.type, - calendarRange: currentRange, - }) - ); - } else { + if (!calendar) { console.warn("Calendar not found for id:", calendarId); + return; + } + calendarsToRefreshMap.set(calendarId, calendar); + }); +} + +function accumulateCalendarsToHide( + calendarPaths: Set, + calendarsToHideSet: Set +) { + calendarPaths.forEach((calendarPath) => { + const calendarId = parseCalendarPath(calendarPath); + if (calendarId) { + calendarsToHideSet.add(calendarId); } }); - const currentSelectedCalendars = JSON.parse( - localStorage.getItem("selectedCalendars") ?? "[]" - ) as string[]; +} - const calendarIdsToHide = [...calendarsToHide] - .map(parseCalendarPath) - .filter((id): id is string => Boolean(id)); +function processCalendarsToRefresh( + dispatch: AppDispatch, + currentRange: { start: Date; end: Date }, + calendarsMap: Map +) { + calendarsMap.forEach((calendar) => { + dispatch( + refreshCalendarWithSyncToken({ + calendar: calendar.calendar, + calType: calendar.type, + calendarRange: currentRange, + }) + ); + }); +} + +function processCalendarsToHide(calendarsToHideSnapshot: Set) { + if (calendarsToHideSnapshot.size === 0) return; + + let currentSelectedCalendars: string[]; + + try { + const stored = localStorage.getItem("selectedCalendars") ?? "[]"; + currentSelectedCalendars = JSON.parse(stored); + } catch (error) { + console.warn("Failed to parse selectedCalendars from localStorage:", error); + return; + } const updatedSelectedCalendars = currentSelectedCalendars.filter( - (id) => !calendarIdsToHide.includes(id) + (id) => !calendarsToHideSnapshot.has(id) ); setSelectedCalendars(updatedSelectedCalendars);