From a1537241306402562057b4127126b850f2aa5a62 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 00:14:20 -0300 Subject: [PATCH 1/3] perf(room): parallelize room open and short-circuit rid lookup Two changes to the open flow: 1. The `await import()` calls inside `useOpenRoom.queryFn` for the stores, `LegacyRoomManager`, and `RoomManager` are promoted to static top-level imports. Those modules are always loaded when a room opens, so the dynamic split only added latency on top of the already serial Meteor method call. 2. Add `tryCacheShortcut()` that resolves the URL reference to a rid using `Subscriptions.state` + `Rooms.state`, then wire it through React Query both as `placeholderData` (so consumers see `data` synchronously on the first render and `RoomProvider` mounts a tick earlier) and as the first thing `queryFn` checks. When the cache can answer, queryFn calls `LegacyRoomManager.open` and returns without touching the network. The function bails out for cases the server still needs: anonymous browsing, public-channel preview, DM URLs that still use a username (old links), or subscriptions where `open === false`. The cache-miss path is unchanged. Additionally, `RoomProvider` now kicks off `RoomHistoryManager.getMore` as soon as the room lands in the store and prefetches `/v1/rooms.roles`, so history and roles run in parallel with the rest of the open flow rather than waiting for `RoomBody`'s scroll observer. The existing `isLoading` guard in `useGetMore` prevents duplicate calls. --- .../client/views/room/hooks/useOpenRoom.ts | 49 ++++++++++++++++--- .../views/room/providers/RoomProvider.tsx | 18 ++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index 284c632921e1e..76dd6d9cc2d18 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -2,16 +2,18 @@ import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typin import { getObjectKeys } from '@rocket.chat/tools'; import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useOpenRoomMutation } from './useOpenRoomMutation'; +import { LegacyRoomManager } from '../../../../app/ui-utils/client'; import { roomFields } from '../../../../lib/publishFields'; +import { RoomManager } from '../../../lib/RoomManager'; import { NotAuthorizedError } from '../../../lib/errors/NotAuthorizedError'; import { NotSubscribedToRoomError } from '../../../lib/errors/NotSubscribedToRoomError'; import { OldUrlRoomError } from '../../../lib/errors/OldUrlRoomError'; import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError'; import { roomsQueryKeys } from '../../../lib/queryKeys'; -import { Rooms } from '../../../stores'; +import { Rooms, Subscriptions } from '../../../stores'; export function useOpenRoom({ type, reference }: { type: RoomType; reference: string }) { const user = useUser(); @@ -22,11 +24,48 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st const directRoute = useRoute('direct'); const openRoom = useOpenRoomMutation(); + // Try to resolve the reference to a known rid using locally cached subscriptions and rooms. + // Returns null when the cache can't safely answer (anonymous user, public preview, DM redirect + // from a username URL, missing record). The caller still validates side effects. + const tryCacheShortcut = useCallback((): { rid: IRoom['_id'] } | undefined => { + if (!user?._id || !reference || !type) { + return undefined; + } + const sub = Subscriptions.state.find((record) => record.rid === reference || record.name === reference); + if (!sub) { + return undefined; + } + const room = Rooms.state.get(sub.rid); + if (!room) { + return undefined; + } + // DM URLs that still reference a username (rather than the rid) need the redirect path in + // queryFn — let the server resolve. + if (type === 'd' && reference !== sub.rid) { + return undefined; + } + // Skip when the user hasn't actually opened the subscription yet; queryFn will call + // openRoom.mutateAsync to flip sub.open. + if (sub.open === false) { + return undefined; + } + return { rid: sub.rid }; + }, [reference, type, user?._id]); + const result = useQuery({ // we need to add uid and username here because `user` is not loaded all at once (see UserProvider -> Meteor.user()) queryKey: roomsQueryKeys.roomReference(reference, type, user?._id, user?.username), + // Render immediately from local cache when we already know the rid; queryFn still runs in + // the background to revalidate permissions / fetch fresh room fields. + placeholderData: tryCacheShortcut, + queryFn: async (): Promise<{ rid: IRoom['_id'] }> => { + const cached = tryCacheShortcut(); + if (cached) { + LegacyRoomManager.open({ typeName: type + reference, rid: cached.rid }); + return cached; + } if ((user && !user.username) || (!user && !allowAnonymousRead)) { throw new NotAuthorizedError(); } @@ -58,8 +97,6 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st throw new RoomNotFoundError(undefined, { type, reference }); } - const { Rooms, Subscriptions } = await import('../../../stores'); - const unsetKeys = getObjectKeys(roomData).filter((key) => !(key in roomFields)); unsetKeys.forEach((key) => { delete roomData[key]; @@ -72,8 +109,6 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st throw new TypeError('room is undefined'); } - const { LegacyRoomManager } = await import('../../../../app/ui-utils/client'); - const sub = Subscriptions.state.find((record) => record.rid === reference || record.name === reference); if (reference !== undefined && room._id !== reference && type === 'd') { @@ -83,8 +118,6 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st throw new OldUrlRoomError(undefined, { rid: room._id }); } - const { RoomManager } = await import('../../../lib/RoomManager'); - // if user doesn't exist at this point, anonymous read is enabled, otherwise an error would have been thrown if (user && !sub && !hasPreviewPermission && isPublicRoom(room)) { throw new NotSubscribedToRoomError(undefined, { rid: room._id }); diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index d4fb329c54885..595dabe1ed9d2 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -8,9 +8,10 @@ import UserCardProvider from './UserCardProvider'; import { useRedirectOnSettingsChanged } from './hooks/useRedirectOnSettingsChanged'; import { useUsersNameChanged } from './hooks/useUsersNameChanged'; import { UserAction } from '../../../../app/ui/client/lib/UserAction'; -import { useRoomHistoryState } from '../../../../app/ui-utils/client/lib/RoomHistoryManager'; +import { RoomHistoryManager, useRoomHistoryState } from '../../../../app/ui-utils/client/lib/RoomHistoryManager'; import { omit } from '../../../../lib/utils/omit'; import { useFireGlobalEvent } from '../../../hooks/useFireGlobalEvent'; +import { useRoomRolesQuery } from '../../../hooks/useRoomRolesQuery'; import { RoomManager } from '../../../lib/RoomManager'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; import ImageGalleryProvider from '../../../providers/ImageGalleryProvider'; @@ -81,6 +82,21 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { }; }, [rid]); + // Prefetch first batch of history in parallel with room metadata fetches, instead of waiting + // for RoomBody's scroll/resize observer in useGetMore to fire. + useEffect(() => { + if (!room) { + return; + } + if (RoomHistoryManager.isLoaded(rid) || RoomHistoryManager.isLoading(rid)) { + return; + } + void RoomHistoryManager.getMore(rid); + }, [rid, room]); + + // Prefetch room roles alongside history so message rendering doesn't trigger a late fetch. + useRoomRolesQuery(rid, { enabled: !!room }); + const subscribed = !!subscritionFromLocal; useEffect(() => { From da687e64ffebae6c3052ec2df928b445b0561ada Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 00:14:37 -0300 Subject: [PATCH 2/3] perf(room): defer sidebar menu mount, gate auto-pagination, lower history throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling a warm room change in DevTools showed three sources of wasted time after the network was no longer the bottleneck: 1. Sidebar item `onPointerEnter` mounted `RoomMenu` synchronously inside the same task that handled the click navigation. RoomMenu's hooks (`useUserSubscription`, two `usePermission`s, `useSetting`, `useOmnichannelPrioritiesMenu`'s `useEndpoint`, `useUserPresence`) added ~700ms to a single pointerover event on a slower CPU profile. The menu is rarely the user's actual target on hover. 2. `useGetMore`'s `MutationObserver` / `ResizeObserver` fired during the initial message-render cascade and pulled a second history page immediately, because `scrollTop` was still 0 before scroll-to-bottom ran. The second `loadHistory` call ate ~420ms of CPU + wait while the user gained nothing visually — the first page already filled the viewport. 3. `RoomHistoryManager.run()` had a hardcoded 500ms cooldown between consecutive `loadHistory` calls, which forced ~330ms of pure waiting on the second `getMore`. Changes: - New `useDeferredMenuMount` hook that schedules sidebar-item menu mounting via `requestIdleCallback` (with a `setTimeout` fallback). Hover requests an idle mount; focus and a direct pointer-down on the kebab placeholder mount immediately. Applied to `Extended`, `Medium`, and `Condensed` sidebar item variants. - `useGetMore` now distinguishes real user input (`wheel` / `touchmove` / `PageUp` / `PageDown` / `ArrowUp` / `ArrowDown` / `Home` / `End`) from programmatic scroll and observer noise. `MutationObserver`, `ResizeObserver`, and `scroll` events no longer call `getMore` unless the user has interacted with the list, except for the `?msg=` jump-to-message surrounding fetch which still fires on initial mount. - `RoomHistoryManager.run()` spacing reduced from 500ms to 100ms. The `loadHistory` server method is itself the rate limit; the larger client spacing only delayed the user's pagination. --- .../ui-utils/client/lib/RoomHistoryManager.ts | 8 ++- apps/meteor/client/sidebar/Item/Condensed.tsx | 17 +++--- apps/meteor/client/sidebar/Item/Extended.tsx | 16 ++--- apps/meteor/client/sidebar/Item/Medium.tsx | 17 +++--- .../sidebar/Item/useDeferredMenuMount.ts | 59 ++++++++++++++++++ .../views/room/body/hooks/useGetMore.spec.tsx | 6 ++ .../views/room/body/hooks/useGetMore.ts | 60 +++++++++++++++---- 7 files changed, 147 insertions(+), 36 deletions(-) create mode 100644 apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 95f94a4e356c7..90f36fb21fc31 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -107,10 +107,14 @@ class RoomHistoryManagerClass extends Emitter { private run(fn: () => void) { const difference = this.lastRequest ? differenceInMilliseconds(new Date(), this.lastRequest) : Infinity; - if (difference > 500) { + // Original cooldown was 500ms which forced ~330ms wait on the second getMore call when a + // user opens a room. Pagination throughput here is bounded by the loadHistory server + // method itself, so a smaller client-side spacing is enough to avoid hammering. + const minSpacingMs = 100; + if (difference > minSpacingMs) { return fn(); } - return setTimeout(fn, 500 - difference); + return setTimeout(fn, minSpacingMs - difference); } public isLoaded(rid: IRoom['_id']) { diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 1f9f522d2844e..df447822de00f 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -1,6 +1,8 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage'; import type { HTMLAttributes, ReactNode } from 'react'; -import { memo, useState } from 'react'; +import { memo } from 'react'; + +import { useDeferredMenuMount } from './useDeferredMenuMount'; type CondensedProps = { title: ReactNode; @@ -18,13 +20,10 @@ type CondensedProps = { } & Omit, 'is'>; const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => { - const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - - const handleFocus = () => setMenuVisibility(true); - const handlePointerEnter = () => setMenuVisibility(true); + const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( - + {avatar && {avatar}} {icon} {title} @@ -32,7 +31,11 @@ const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...prop {actions} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? ( + menu() + ) : ( + + )} )} diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index fde211d74f0d0..17550a58aa1d9 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -10,8 +10,9 @@ import { IconButton, } from '@rocket.chat/fuselage'; import type { HTMLAttributes, ReactNode } from 'react'; -import { memo, useState } from 'react'; +import { memo } from 'react'; +import { useDeferredMenuMount } from './useDeferredMenuMount'; import { useShortTimeAgo } from '../../hooks/useTimeAgo'; type ExtendedProps = { @@ -49,13 +50,10 @@ const Extended = ({ ...props }: ExtendedProps) => { const formatDate = useShortTimeAgo(); - const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - - const handleFocus = () => setMenuVisibility(true); - const handlePointerEnter = () => setMenuVisibility(true); + const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( - + {avatar && {avatar}} @@ -69,7 +67,11 @@ const Extended = ({ {actions} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? ( + menu() + ) : ( + + )} )} diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index 3492d4e55de34..07bd19f4e096c 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -1,6 +1,8 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage'; import type { HTMLAttributes, ReactNode } from 'react'; -import { memo, useState } from 'react'; +import { memo } from 'react'; + +import { useDeferredMenuMount } from './useDeferredMenuMount'; type MediumProps = { title: ReactNode; @@ -17,13 +19,10 @@ type MediumProps = { } & Omit, 'is'>; const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => { - const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - - const handleFocus = () => setMenuVisibility(true); - const handlePointerEnter = () => setMenuVisibility(true); + const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( - + {avatar} {icon} {title} @@ -31,7 +30,11 @@ const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props } {actions} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? ( + menu() + ) : ( + + )} )} diff --git a/apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts b/apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts new file mode 100644 index 0000000000000..4658112f651a6 --- /dev/null +++ b/apps/meteor/client/sidebar/Item/useDeferredMenuMount.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type IdleHandle = { type: 'idle' | 'timeout'; id: number }; + +const schedule = (fn: () => void): IdleHandle => { + if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { + return { type: 'idle', id: window.requestIdleCallback(fn, { timeout: 200 }) }; + } + return { type: 'timeout', id: window.setTimeout(fn, 50) }; +}; + +const cancel = (handle: IdleHandle) => { + if (handle.type === 'idle' && typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(handle.id); + return; + } + window.clearTimeout(handle.id); +}; + +/** + * Defers mounting the sidebar item's RoomMenu until the browser is idle. The menu's hooks + * (useUserSubscription, usePermission, useSetting, useOmnichannelPrioritiesMenu, useUserPresence) + * are not cheap to run synchronously inside the same pointerover/click task as a room navigation, + * so we let the browser finish more urgent work first. + */ +export const useDeferredMenuMount = () => { + const [mounted, setMounted] = useState(typeof window !== 'undefined' && !!window.DISABLE_ANIMATION); + const handleRef = useRef(undefined); + + const requestMount = useCallback(() => { + if (mounted || handleRef.current !== undefined) { + return; + } + handleRef.current = schedule(() => { + handleRef.current = undefined; + setMounted(true); + }); + }, [mounted]); + + const mountNow = useCallback(() => { + if (handleRef.current !== undefined) { + cancel(handleRef.current); + handleRef.current = undefined; + } + setMounted(true); + }, []); + + useEffect( + () => () => { + if (handleRef.current !== undefined) { + cancel(handleRef.current); + handleRef.current = undefined; + } + }, + [], + ); + + return { mounted, requestMount, mountNow }; +}; diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx b/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx index dc91baa7ce1a3..1a388da54b55b 100644 --- a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx +++ b/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx @@ -54,6 +54,9 @@ describe('useGetMore', () => { const scrollableElement = screen.getByTestId('scrollable-element'); scrollableElement.scrollTop = 10; + // Simulate real user input before the scroll — the hook ignores observer / programmatic + // scroll events until the user has interacted with the list. + scrollableElement.dispatchEvent(new Event('wheel')); scrollableElement.dispatchEvent(new Event('scroll')); expect(screen.getByTestId('scrollable-element')).toBeInTheDocument(); @@ -89,6 +92,9 @@ describe('useGetMore', () => { }); const scrollableElement = screen.getByTestId('scrollable-element'); scrollableElement.scrollTop = 700; + // Simulate real user input before the scroll — the hook ignores observer / programmatic + // scroll events until the user has interacted with the list. + scrollableElement.dispatchEvent(new Event('wheel')); scrollableElement.dispatchEvent(new Event('scroll')); expect(screen.getByTestId('scrollable-element')).toBeInTheDocument(); expect(RoomHistoryManager.getMoreNext).toHaveBeenCalledWith('room-id'); diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.ts b/apps/meteor/client/views/room/body/hooks/useGetMore.ts index 52d10ce55d6c6..199c81154842f 100644 --- a/apps/meteor/client/views/room/body/hooks/useGetMore.ts +++ b/apps/meteor/client/views/room/body/hooks/useGetMore.ts @@ -13,6 +13,14 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => { const ref = useSafeRefCallback( useCallback( (element: HTMLElement) => { + // Observers (MutationObserver, ResizeObserver) fire during the initial mount cascade + // as messages are inserted and the virtualizer measures itself. At that point scrollTop + // is still 0 because scroll-to-bottom hasn't run yet, which made checkPositionAndGetMore + // pull a second history page immediately after the first. Gate observer-driven calls on + // real user input (wheel / touch / scroll-affecting keys); programmatic scroll does not + // flip this flag. + let userInteracted = false; + const checkPositionAndGetMore = withThrottling({ wait: 100 })(async () => { if (!element.isConnected) { return; @@ -57,32 +65,58 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => { } }); - const mutationObserver = new MutationObserver((mutations) => { - mutations.forEach(() => { - checkPositionAndGetMore(); - }); - }); + const gatedCheck = () => { + // Surrounding-messages fetch (when navigating to ?msg=...) needs to fire once on + // the initial observer pass before the user has interacted. + const allowedByJumpToMessage = !!msgId && !RoomHistoryManager.isLoaded(rid); + if (!userInteracted && !allowedByJumpToMessage) { + return; + } + checkPositionAndGetMore(); + }; + const mutationObserver = new MutationObserver(gatedCheck); mutationObserver.observe(element, { childList: true, subtree: true }); - const observer = new ResizeObserver(() => { - checkPositionAndGetMore(); - }); - + const observer = new ResizeObserver(gatedCheck); observer.observe(element); - const handleScroll = function () { + const markInteracted = () => { + userInteracted = true; + }; + + const handleKeydown = (e: KeyboardEvent) => { + if ( + e.key === 'PageUp' || + e.key === 'PageDown' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' + ) { + userInteracted = true; + } + }; + + const handleScroll = () => { + if (!userInteracted) { + return; + } checkPositionAndGetMore(); }; - element.addEventListener('scroll', handleScroll, { - passive: true, - }); + element.addEventListener('wheel', markInteracted, { passive: true }); + element.addEventListener('touchmove', markInteracted, { passive: true }); + element.addEventListener('keydown', handleKeydown); + element.addEventListener('scroll', handleScroll, { passive: true }); return () => { observer.disconnect(); mutationObserver.disconnect(); checkPositionAndGetMore.cancel(); + element.removeEventListener('wheel', markInteracted); + element.removeEventListener('touchmove', markInteracted); + element.removeEventListener('keydown', handleKeydown); element.removeEventListener('scroll', handleScroll); }; }, From a31ac9b4f64def8f54046cc63b94a8afb65575e1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 00:14:44 -0300 Subject: [PATCH 3/3] perf(sidebar): defer menu mount on new navigation SidebarItem The new navigation sidebar (views/navigation/sidebar/RoomList) shared the same eager-mount-on-hover pattern as the legacy sidebar items, so RoomMenu still mounted synchronously inside the pointerover handler. Wire it through useDeferredMenuMount so the menu mount is scheduled via requestIdleCallback and the kebab placeholder forces mountNow on direct pointer-down. --- .../navigation/sidebar/RoomList/SidebarItem.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItem.tsx b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItem.tsx index cd0ff4578299a..093decdd2ef14 100644 --- a/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItem.tsx +++ b/apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItem.tsx @@ -2,7 +2,9 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMen import { RoomAvatar } from '@rocket.chat/ui-avatar'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import type { HTMLAttributes, ReactElement, ReactNode } from 'react'; -import { memo, useState } from 'react'; +import { memo } from 'react'; + +import { useDeferredMenuMount } from '../../../../sidebar/Item/useDeferredMenuMount'; type SidebarItemProps = { title: ReactNode; @@ -20,13 +22,10 @@ type SidebarItemProps = { } & Omit, 'is'>; const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...props }: SidebarItemProps) => { - const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); - - const handleFocus = () => setMenuVisibility(true); - const handlePointerEnter = () => setMenuVisibility(true); + const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount(); return ( - + @@ -36,7 +35,11 @@ const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...prop {actions} {menu && ( - {menuVisibility ? menu : } + {menuVisibility ? ( + menu + ) : ( + + )} )}