Skip to content

Commit b92bcc7

Browse files
authored
chore(room): speed up room open by parallelizing fetches and caching (RocketChat#40718)
1 parent 1a4d0f3 commit b92bcc7

10 files changed

Lines changed: 215 additions & 52 deletions

File tree

apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,14 @@ class RoomHistoryManagerClass extends Emitter {
107107

108108
private run(fn: () => void) {
109109
const difference = this.lastRequest ? differenceInMilliseconds(new Date(), this.lastRequest) : Infinity;
110-
if (difference > 500) {
110+
// Original cooldown was 500ms which forced ~330ms wait on the second getMore call when a
111+
// user opens a room. Pagination throughput here is bounded by the loadHistory server
112+
// method itself, so a smaller client-side spacing is enough to avoid hammering.
113+
const minSpacingMs = 100;
114+
if (difference > minSpacingMs) {
111115
return fn();
112116
}
113-
return setTimeout(fn, 500 - difference);
117+
return setTimeout(fn, minSpacingMs - difference);
114118
}
115119

116120
public isLoaded(rid: IRoom['_id']) {

apps/meteor/client/sidebar/Item/Condensed.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage';
22
import type { HTMLAttributes, ReactNode } from 'react';
3-
import { memo, useState } from 'react';
3+
import { memo } from 'react';
4+
5+
import { useDeferredMenuMount } from './useDeferredMenuMount';
46

57
type CondensedProps = {
68
title: ReactNode;
@@ -18,21 +20,22 @@ type CondensedProps = {
1820
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;
1921

2022
const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => {
21-
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);
22-
23-
const handleFocus = () => setMenuVisibility(true);
24-
const handlePointerEnter = () => setMenuVisibility(true);
23+
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
2524

2625
return (
27-
<SidebarV2Item title={title} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
26+
<SidebarV2Item title={title} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
2827
{avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>}
2928
{icon}
3029
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
3130
{badges}
3231
{actions}
3332
{menu && (
3433
<SidebarV2ItemMenu>
35-
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
34+
{menuVisibility ? (
35+
menu()
36+
) : (
37+
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
38+
)}
3639
</SidebarV2ItemMenu>
3740
)}
3841
</SidebarV2Item>

apps/meteor/client/sidebar/Item/Extended.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import {
1010
IconButton,
1111
} from '@rocket.chat/fuselage';
1212
import type { HTMLAttributes, ReactNode } from 'react';
13-
import { memo, useState } from 'react';
13+
import { memo } from 'react';
1414

15+
import { useDeferredMenuMount } from './useDeferredMenuMount';
1516
import { useShortTimeAgo } from '../../hooks/useTimeAgo';
1617

1718
type ExtendedProps = {
@@ -49,13 +50,10 @@ const Extended = ({
4950
...props
5051
}: ExtendedProps) => {
5152
const formatDate = useShortTimeAgo();
52-
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);
53-
54-
const handleFocus = () => setMenuVisibility(true);
55-
const handlePointerEnter = () => setMenuVisibility(true);
53+
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
5654

5755
return (
58-
<SidebarV2Item title={title} href={href} selected={selected} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
56+
<SidebarV2Item title={title} href={href} selected={selected} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
5957
{avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>}
6058
<SidebarV2ItemCol>
6159
<SidebarV2ItemRow>
@@ -69,7 +67,11 @@ const Extended = ({
6967
{actions}
7068
{menu && (
7169
<SidebarV2ItemMenu>
72-
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
70+
{menuVisibility ? (
71+
menu()
72+
) : (
73+
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
74+
)}
7375
</SidebarV2ItemMenu>
7476
)}
7577
</SidebarV2ItemRow>

apps/meteor/client/sidebar/Item/Medium.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage';
22
import type { HTMLAttributes, ReactNode } from 'react';
3-
import { memo, useState } from 'react';
3+
import { memo } from 'react';
4+
5+
import { useDeferredMenuMount } from './useDeferredMenuMount';
46

57
type MediumProps = {
68
title: ReactNode;
@@ -17,21 +19,22 @@ type MediumProps = {
1719
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;
1820

1921
const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => {
20-
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);
21-
22-
const handleFocus = () => setMenuVisibility(true);
23-
const handlePointerEnter = () => setMenuVisibility(true);
22+
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
2423

2524
return (
26-
<SidebarV2Item title={title} {...props} onFocus={handleFocus} onPointerEnter={handlePointerEnter}>
25+
<SidebarV2Item title={title} {...props} onFocus={mountNow} onPointerEnter={requestMount}>
2726
<SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>
2827
{icon}
2928
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
3029
{badges}
3130
{actions}
3231
{menu && (
3332
<SidebarV2ItemMenu>
34-
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
33+
{menuVisibility ? (
34+
menu()
35+
) : (
36+
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
37+
)}
3538
</SidebarV2ItemMenu>
3639
)}
3740
</SidebarV2Item>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
type IdleHandle = { type: 'idle' | 'timeout'; id: number };
4+
5+
const schedule = (fn: () => void): IdleHandle => {
6+
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
7+
return { type: 'idle', id: window.requestIdleCallback(fn, { timeout: 200 }) };
8+
}
9+
return { type: 'timeout', id: window.setTimeout(fn, 50) };
10+
};
11+
12+
const cancel = (handle: IdleHandle) => {
13+
if (handle.type === 'idle' && typeof window.cancelIdleCallback === 'function') {
14+
window.cancelIdleCallback(handle.id);
15+
return;
16+
}
17+
window.clearTimeout(handle.id);
18+
};
19+
20+
/**
21+
* Defers mounting the sidebar item's RoomMenu until the browser is idle. The menu's hooks
22+
* (useUserSubscription, usePermission, useSetting, useOmnichannelPrioritiesMenu, useUserPresence)
23+
* are not cheap to run synchronously inside the same pointerover/click task as a room navigation,
24+
* so we let the browser finish more urgent work first.
25+
*/
26+
export const useDeferredMenuMount = () => {
27+
const [mounted, setMounted] = useState(typeof window !== 'undefined' && !!window.DISABLE_ANIMATION);
28+
const handleRef = useRef<IdleHandle | undefined>(undefined);
29+
30+
const requestMount = useCallback(() => {
31+
if (mounted || handleRef.current !== undefined) {
32+
return;
33+
}
34+
handleRef.current = schedule(() => {
35+
handleRef.current = undefined;
36+
setMounted(true);
37+
});
38+
}, [mounted]);
39+
40+
const mountNow = useCallback(() => {
41+
if (handleRef.current !== undefined) {
42+
cancel(handleRef.current);
43+
handleRef.current = undefined;
44+
}
45+
setMounted(true);
46+
}, []);
47+
48+
useEffect(
49+
() => () => {
50+
if (handleRef.current !== undefined) {
51+
cancel(handleRef.current);
52+
handleRef.current = undefined;
53+
}
54+
},
55+
[],
56+
);
57+
58+
return { mounted, requestMount, mountNow };
59+
};

apps/meteor/client/views/navigation/sidebar/RoomList/SidebarItem.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMen
22
import { RoomAvatar } from '@rocket.chat/ui-avatar';
33
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
44
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
5-
import { memo, useState } from 'react';
5+
import { memo } from 'react';
6+
7+
import { useDeferredMenuMount } from '../../../../sidebar/Item/useDeferredMenuMount';
68

79
type SidebarItemProps = {
810
title: ReactNode;
@@ -20,13 +22,10 @@ type SidebarItemProps = {
2022
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;
2123

2224
const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...props }: SidebarItemProps) => {
23-
const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION);
24-
25-
const handleFocus = () => setMenuVisibility(true);
26-
const handlePointerEnter = () => setMenuVisibility(true);
25+
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();
2726

2827
return (
29-
<SidebarV2Item {...props} title={title} onFocus={handleFocus} onPointerEnter={handlePointerEnter} aria-selected={props.selected}>
28+
<SidebarV2Item {...props} title={title} onFocus={mountNow} onPointerEnter={requestMount} aria-selected={props.selected}>
3029
<SidebarV2ItemAvatarWrapper>
3130
<RoomAvatar size='x20' room={{ ...room, _id: room.rid || room._id, type: room.t }} />
3231
</SidebarV2ItemAvatarWrapper>
@@ -36,7 +35,11 @@ const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...prop
3635
{actions}
3736
{menu && (
3837
<SidebarV2ItemMenu>
39-
{menuVisibility ? menu : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' />}
38+
{menuVisibility ? (
39+
menu
40+
) : (
41+
<IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-v2-item__menu icon='kebab' onPointerDown={mountNow} />
42+
)}
4043
</SidebarV2ItemMenu>
4144
)}
4245
</SidebarV2Item>

apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ describe('useGetMore', () => {
5454

5555
const scrollableElement = screen.getByTestId('scrollable-element');
5656
scrollableElement.scrollTop = 10;
57+
// Simulate real user input before the scroll — the hook ignores observer / programmatic
58+
// scroll events until the user has interacted with the list.
59+
scrollableElement.dispatchEvent(new Event('wheel'));
5760
scrollableElement.dispatchEvent(new Event('scroll'));
5861

5962
expect(screen.getByTestId('scrollable-element')).toBeInTheDocument();
@@ -89,6 +92,9 @@ describe('useGetMore', () => {
8992
});
9093
const scrollableElement = screen.getByTestId('scrollable-element');
9194
scrollableElement.scrollTop = 700;
95+
// Simulate real user input before the scroll — the hook ignores observer / programmatic
96+
// scroll events until the user has interacted with the list.
97+
scrollableElement.dispatchEvent(new Event('wheel'));
9298
scrollableElement.dispatchEvent(new Event('scroll'));
9399
expect(screen.getByTestId('scrollable-element')).toBeInTheDocument();
94100
expect(RoomHistoryManager.getMoreNext).toHaveBeenCalledWith('room-id');

apps/meteor/client/views/room/body/hooks/useGetMore.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {
1313
const ref = useSafeRefCallback(
1414
useCallback(
1515
(element: HTMLElement) => {
16+
// Observers (MutationObserver, ResizeObserver) fire during the initial mount cascade
17+
// as messages are inserted and the virtualizer measures itself. At that point scrollTop
18+
// is still 0 because scroll-to-bottom hasn't run yet, which made checkPositionAndGetMore
19+
// pull a second history page immediately after the first. Gate observer-driven calls on
20+
// real user input (wheel / touch / scroll-affecting keys); programmatic scroll does not
21+
// flip this flag.
22+
let userInteracted = false;
23+
1624
const checkPositionAndGetMore = withThrottling({ wait: 100 })(async () => {
1725
if (!element.isConnected) {
1826
return;
@@ -57,32 +65,58 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {
5765
}
5866
});
5967

60-
const mutationObserver = new MutationObserver((mutations) => {
61-
mutations.forEach(() => {
62-
checkPositionAndGetMore();
63-
});
64-
});
68+
const gatedCheck = () => {
69+
// Surrounding-messages fetch (when navigating to ?msg=...) needs to fire once on
70+
// the initial observer pass before the user has interacted.
71+
const allowedByJumpToMessage = !!msgId && !RoomHistoryManager.isLoaded(rid);
72+
if (!userInteracted && !allowedByJumpToMessage) {
73+
return;
74+
}
75+
checkPositionAndGetMore();
76+
};
6577

78+
const mutationObserver = new MutationObserver(gatedCheck);
6679
mutationObserver.observe(element, { childList: true, subtree: true });
6780

68-
const observer = new ResizeObserver(() => {
69-
checkPositionAndGetMore();
70-
});
71-
81+
const observer = new ResizeObserver(gatedCheck);
7282
observer.observe(element);
7383

74-
const handleScroll = function () {
84+
const markInteracted = () => {
85+
userInteracted = true;
86+
};
87+
88+
const handleKeydown = (e: KeyboardEvent) => {
89+
if (
90+
e.key === 'PageUp' ||
91+
e.key === 'PageDown' ||
92+
e.key === 'ArrowUp' ||
93+
e.key === 'ArrowDown' ||
94+
e.key === 'Home' ||
95+
e.key === 'End'
96+
) {
97+
userInteracted = true;
98+
}
99+
};
100+
101+
const handleScroll = () => {
102+
if (!userInteracted) {
103+
return;
104+
}
75105
checkPositionAndGetMore();
76106
};
77107

78-
element.addEventListener('scroll', handleScroll, {
79-
passive: true,
80-
});
108+
element.addEventListener('wheel', markInteracted, { passive: true });
109+
element.addEventListener('touchmove', markInteracted, { passive: true });
110+
element.addEventListener('keydown', handleKeydown);
111+
element.addEventListener('scroll', handleScroll, { passive: true });
81112

82113
return () => {
83114
observer.disconnect();
84115
mutationObserver.disconnect();
85116
checkPositionAndGetMore.cancel();
117+
element.removeEventListener('wheel', markInteracted);
118+
element.removeEventListener('touchmove', markInteracted);
119+
element.removeEventListener('keydown', handleKeydown);
86120
element.removeEventListener('scroll', handleScroll);
87121
};
88122
},

0 commit comments

Comments
 (0)