From 115624aaf76a467eaeab1b4a445084a58de8564d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 5 Nov 2025 16:31:53 -0300 Subject: [PATCH 1/4] fix: high cpu usage with large amount of channels --- .../client/cachedStores/RoomsCachedStore.ts | 33 +++++++++++++++---- .../cachedStores/SubscriptionsCachedStore.ts | 26 +++++++++++++-- apps/meteor/client/cachedStores/config.ts | 12 +++++++ 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 apps/meteor/client/cachedStores/config.ts diff --git a/apps/meteor/client/cachedStores/RoomsCachedStore.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts index 6ab8c929e73b0..094a349cb169a 100644 --- a/apps/meteor/client/cachedStores/RoomsCachedStore.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -4,6 +4,7 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; import { Rooms, Subscriptions } from '../stores'; +import { isOptimized } from './config'; class RoomsCachedStore extends PrivateCachedStore { constructor() { @@ -14,7 +15,7 @@ class RoomsCachedStore extends PrivateCachedStore { }); } - private merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom { + protected merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom { return { ...sub, encrypted: room.encrypted, @@ -88,6 +89,14 @@ class RoomsCachedStore extends PrivateCachedStore { if (action === 'removed') return; + this.handleChanged(room); + } + + /** + * Lower performance implementation that rebuilds the entire subscriptions Map on each room change. + * See {@link RoomsCachedStoreOptimized} for the improved version. + */ + protected handleChanged(room: IRoom): void { Subscriptions.use.getState().update( (record) => record.rid === room._id, (sub) => this.merge(room, sub), @@ -97,10 +106,7 @@ class RoomsCachedStore extends PrivateCachedStore { protected override handleSyncEvent(action: 'removed' | 'changed', room: IRoom): void { if (action === 'removed') return; - Subscriptions.use.getState().update( - (record) => record.rid === room._id, - (sub) => this.merge(room, sub), - ); + this.handleChanged(room); } protected deserializeFromCache(record: unknown) { @@ -114,6 +120,21 @@ class RoomsCachedStore extends PrivateCachedStore { } } -const instance = new RoomsCachedStore(); +/** + * Previous implementation rebuilt the entire subscriptions Map for every room change. + * For large channel lists this is expensive and triggered on each message (room lm/lastMessage changes). + * We now perform a targeted lookup and store only the merged subscription, causing just one Map copy. + */ +class RoomsCachedStoreOptimized extends RoomsCachedStore { + protected override handleChanged(room: IRoom): void { + const state = Subscriptions.use.getState(); + const existing = state.find((record) => record.rid === room._id); + if (existing) { + state.store(this.merge(room, existing)); + } + } +} + +const instance = isOptimized ? new RoomsCachedStoreOptimized() : new RoomsCachedStore(); export { instance as RoomsCachedStore }; diff --git a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts index 6e5fea6c2a488..78c910e68d0f0 100644 --- a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts +++ b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts @@ -1,9 +1,10 @@ -import type { IOmnichannelRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings'; import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; import { Rooms, Subscriptions } from '../stores'; +import { isOptimized } from './config'; class SubscriptionsCachedStore extends PrivateCachedStore { constructor() { @@ -14,8 +15,16 @@ class SubscriptionsCachedStore extends PrivateCachedStore r._id === rid); + } + protected override mapRecord(subscription: ISubscription): SubscriptionWithRoom { - const room = Rooms.use.getState().find((r) => r._id === subscription.rid); + const room = this.getRoomById(subscription.rid); const lastRoomUpdate = room?.lm || subscription.ts || room?.ts; @@ -90,6 +99,17 @@ class SubscriptionsCachedStore extends PrivateCachedStore { + const value = localStorage.getItem('roomsCachedStoreOptimized'); + return value === 'true' || value === null; +})(); + +if (isOptimized) { + console.info('RoomsCachedStore: Using optimized implementation'); +} else { + console.warn('RoomsCachedStore: Using non-optimized implementation'); +} + +export { isOptimized }; From 8ccb957f46c0fc81448813cfdb3d433ecc0542c7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 6 Nov 2025 15:41:00 -0300 Subject: [PATCH 2/4] fix: optimize unread subscription handling to reduce CPU usage --- .../views/root/hooks/loggedIn/useUnread.ts | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts b/apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts index 039212e149f40..e27a91ed62e33 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts @@ -1,6 +1,6 @@ import { manageFavicon } from '@rocket.chat/favicon'; import { useSession, useSessionDispatch, useUserPreference, useUserSubscriptions } from '@rocket.chat/ui-contexts'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent'; @@ -8,6 +8,8 @@ const query = { open: { $ne: false }, hideUnreadStatus: { $ne: true }, archived: const options = { fields: { unread: 1, alert: 1, rid: 1, t: 1, name: 1, ls: 1, unreadAlert: 1, fname: 1, prid: 1 } }; const updateFavicon = manageFavicon(); +type UnreadData = { unread: number; alert: boolean | undefined; unreadAlert: string | undefined }; + export const useUnread = () => { const unreadAlertEnabled = useUserPreference('unreadAlert'); const setUnread = useSessionDispatch('unread'); @@ -18,37 +20,48 @@ export const useUnread = () => { const subscriptions = useUserSubscriptions(query, options); + // We keep a lightweight snapshot of the last emitted per-subscription unread state so we only + // fire "unread-changed-by-subscription" for subscriptions whose unread-relevant fields changed. + // Previously we emitted one global event per subscription on ANY change, which scaled O(N) + // with the user subscription count (thousands) for every single message event, dominating CPU. + const prevSubsRef = useRef(new Map()); + useEffect(() => { - let unreadAlert: false | '•' = false; + let badgeIndicator: false | '•' = false; + let unreadCount = 0; + const nextSnapshot = new Map(); - const unreadCount = subscriptions.reduce((ret, subscription) => { - fireEventUnreadChangedBySubscription(subscription); + for (const subscription of subscriptions) { + const { rid, unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert } = subscription; + const prev = prevSubsRef.current.get(rid); + // Emit per-sub event only if something that influences unread UI changed. + if (!prev || prev.unread !== unreadValue || prev.alert !== alert || prev.unreadAlert !== subscriptionUnreadAlert) { + fireEventUnreadChangedBySubscription(subscription); + } + nextSnapshot.set(rid, { unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert }); - if (subscription.alert || subscription.unread > 0) { - // Increment the total unread count. - if (subscription.alert === true && subscription.unreadAlert !== 'nothing') { - if (subscription.unreadAlert === 'all' || unreadAlertEnabled !== false) { - unreadAlert = '•'; + if (alert || unreadValue > 0) { + if (alert === true && subscriptionUnreadAlert !== 'nothing') { + if (subscriptionUnreadAlert === 'all' || unreadAlertEnabled !== false) { + badgeIndicator = '•'; } } - return ret + subscription.unread; + unreadCount += unreadValue; } - return ret; - }, 0); + } + + prevSubsRef.current = nextSnapshot; // swap snapshot if (unreadCount > 0) { - if (unreadCount > 999) { - setUnread('999+'); - } else { - setUnread(unreadCount); - } - } else if (unreadAlert !== false) { - setUnread(unreadAlert); + setUnread(unreadCount > 999 ? '999+' : unreadCount); + } else if (badgeIndicator !== false) { + setUnread(badgeIndicator); } else { setUnread(''); } + fireEventUnreadChanged(unreadCount); - }, [setUnread, unread, subscriptions, unreadAlertEnabled, fireEventUnreadChangedBySubscription, fireEventUnreadChanged]); + }, [setUnread, subscriptions, unreadAlertEnabled, fireEventUnreadChangedBySubscription, fireEventUnreadChanged]); useEffect(() => { updateFavicon(unread); From fbbb225dc53ea21b63cfcfa5d8035cf49816a3aa Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 6 Nov 2025 18:00:27 -0300 Subject: [PATCH 3/4] chore: revert unneeded changes --- .../client/cachedStores/RoomsCachedStore.ts | 33 ++++--------------- .../cachedStores/SubscriptionsCachedStore.ts | 26 ++------------- apps/meteor/client/cachedStores/config.ts | 12 ------- 3 files changed, 9 insertions(+), 62 deletions(-) delete mode 100644 apps/meteor/client/cachedStores/config.ts diff --git a/apps/meteor/client/cachedStores/RoomsCachedStore.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts index 094a349cb169a..6ab8c929e73b0 100644 --- a/apps/meteor/client/cachedStores/RoomsCachedStore.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -4,7 +4,6 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; import { Rooms, Subscriptions } from '../stores'; -import { isOptimized } from './config'; class RoomsCachedStore extends PrivateCachedStore { constructor() { @@ -15,7 +14,7 @@ class RoomsCachedStore extends PrivateCachedStore { }); } - protected merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom { + private merge(room: IRoom, sub: SubscriptionWithRoom): SubscriptionWithRoom { return { ...sub, encrypted: room.encrypted, @@ -89,14 +88,6 @@ class RoomsCachedStore extends PrivateCachedStore { if (action === 'removed') return; - this.handleChanged(room); - } - - /** - * Lower performance implementation that rebuilds the entire subscriptions Map on each room change. - * See {@link RoomsCachedStoreOptimized} for the improved version. - */ - protected handleChanged(room: IRoom): void { Subscriptions.use.getState().update( (record) => record.rid === room._id, (sub) => this.merge(room, sub), @@ -106,7 +97,10 @@ class RoomsCachedStore extends PrivateCachedStore { protected override handleSyncEvent(action: 'removed' | 'changed', room: IRoom): void { if (action === 'removed') return; - this.handleChanged(room); + Subscriptions.use.getState().update( + (record) => record.rid === room._id, + (sub) => this.merge(room, sub), + ); } protected deserializeFromCache(record: unknown) { @@ -120,21 +114,6 @@ class RoomsCachedStore extends PrivateCachedStore { } } -/** - * Previous implementation rebuilt the entire subscriptions Map for every room change. - * For large channel lists this is expensive and triggered on each message (room lm/lastMessage changes). - * We now perform a targeted lookup and store only the merged subscription, causing just one Map copy. - */ -class RoomsCachedStoreOptimized extends RoomsCachedStore { - protected override handleChanged(room: IRoom): void { - const state = Subscriptions.use.getState(); - const existing = state.find((record) => record.rid === room._id); - if (existing) { - state.store(this.merge(room, existing)); - } - } -} - -const instance = isOptimized ? new RoomsCachedStoreOptimized() : new RoomsCachedStore(); +const instance = new RoomsCachedStore(); export { instance as RoomsCachedStore }; diff --git a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts index 78c910e68d0f0..6e5fea6c2a488 100644 --- a/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts +++ b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts @@ -1,10 +1,9 @@ -import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IRoomWithRetentionPolicy, ISubscription } from '@rocket.chat/core-typings'; import { DEFAULT_SLA_CONFIG, isRoomNativeFederated, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { PrivateCachedStore } from '../lib/cachedStores/CachedStore'; import { Rooms, Subscriptions } from '../stores'; -import { isOptimized } from './config'; class SubscriptionsCachedStore extends PrivateCachedStore { constructor() { @@ -15,16 +14,8 @@ class SubscriptionsCachedStore extends PrivateCachedStore r._id === rid); - } - protected override mapRecord(subscription: ISubscription): SubscriptionWithRoom { - const room = this.getRoomById(subscription.rid); + const room = Rooms.use.getState().find((r) => r._id === subscription.rid); const lastRoomUpdate = room?.lm || subscription.ts || room?.ts; @@ -99,17 +90,6 @@ class SubscriptionsCachedStore extends PrivateCachedStore { - const value = localStorage.getItem('roomsCachedStoreOptimized'); - return value === 'true' || value === null; -})(); - -if (isOptimized) { - console.info('RoomsCachedStore: Using optimized implementation'); -} else { - console.warn('RoomsCachedStore: Using non-optimized implementation'); -} - -export { isOptimized }; From 51ff4790c495eb1600cc67950cb1c87aa4ff0af4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 7 Nov 2025 11:33:36 -0300 Subject: [PATCH 4/4] chore: add changeset --- .changeset/quiet-cars-smile.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-cars-smile.md diff --git a/.changeset/quiet-cars-smile.md b/.changeset/quiet-cars-smile.md new file mode 100644 index 0000000000000..be6567695c055 --- /dev/null +++ b/.changeset/quiet-cars-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes client slowdown for users with large amount of channels