Skip to content

Commit 18cdba0

Browse files
authored
Merge pull request #694 from GetStream/upgrade-virtuoso
CRS-384 Upgrade react-virtuoso to v1
2 parents d420cb3 + d645285 commit 18cdba0

File tree

11 files changed

+376
-124
lines changed

11 files changed

+376
-124
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"react-markdown": "^5.0.3",
4343
"react-player": "^2.7.0",
4444
"react-textarea-autosize": "^8.3.0",
45-
"react-virtuoso": "^0.20.3",
45+
"react-virtuoso": "1.5.9",
4646
"textarea-caret": "^3.1.0",
4747
"uuid": "^8.3.1"
4848
},

src/components/MessageList/VirtualizedMessageList.js

Lines changed: 114 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
// @ts-check
2-
import React, {
3-
useContext,
4-
useState,
5-
useEffect,
6-
useCallback,
7-
useRef,
8-
} from 'react';
2+
import React, { useCallback, useContext, useMemo, useRef } from 'react';
93
import { Virtuoso } from 'react-virtuoso';
10-
11-
import { smartRender } from '../../utils';
12-
import MessageNotification from './MessageNotification';
134
import { ChannelContext, TranslationContext } from '../../context';
5+
import { smartRender } from '../../utils';
6+
import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
147
import { EventComponent } from '../EventComponent';
158
import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading';
16-
import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
179
import {
18-
FixedHeightMessage,
10+
FixedHeightMessage as DefaultMessage,
1911
MessageDeleted as DefaultMessageDeleted,
2012
} from '../Message';
13+
import { useNewMessageNotification } from './hooks/useNewMessageNotification';
14+
import { usePrependedMessagesCount } from './hooks/usePrependMessagesCount';
15+
import { useShouldForceScrollToBottom } from './hooks/useShouldForceScrollToBottom';
16+
import MessageNotification from './MessageNotification';
17+
18+
const PREPEND_OFFSET = 10 ** 7;
2119

2220
/**
2321
* VirtualizedMessageList - This component renders a list of messages in a virtual list. Its a consumer of [Channel Context](https://getstream.github.io/stream-chat-react/#channel)
24-
* It is pretty fast for rendering thousands of messages but it needs its Message component to have fixed height
2522
* @example ../../docs/VirtualizedMessageList.md
2623
* @type {React.FC<import('types').VirtualizedMessageListInternalProps>}
2724
*/
@@ -32,91 +29,117 @@ const VirtualizedMessageList = ({
3229
hasMore,
3330
loadingMore,
3431
messageLimit = 100,
35-
overscan = 200,
32+
overscan = 0,
3633
shouldGroupByUser = false,
3734
customMessageRenderer,
35+
// TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component
3836
scrollSeekPlaceHolder,
39-
Message = FixedHeightMessage,
37+
Message = DefaultMessage,
4038
MessageSystem = EventComponent,
4139
MessageDeleted = DefaultMessageDeleted,
4240
TypingIndicator = null,
4341
LoadingIndicator = DefaultLoadingIndicator,
4442
EmptyStateIndicator = DefaultEmptyStateIndicator,
43+
stickToBottomScrollBehavior = 'smooth',
4544
}) => {
4645
const { t } = useContext(TranslationContext);
47-
const [newMessagesNotification, setNewMessagesNotification] = useState(false);
4846

4947
const virtuoso = useRef(
50-
/** @type {import('react-virtuoso').VirtuosoMethods | undefined} */ (undefined),
48+
/** @type {import('react-virtuoso').VirtuosoHandle | undefined} */ (undefined),
49+
);
50+
51+
const {
52+
atBottom,
53+
setNewMessagesNotification,
54+
newMessagesNotification,
55+
} = useNewMessageNotification(messages, client.userID);
56+
57+
const numItemsPrepended = usePrependedMessagesCount(messages);
58+
59+
const shouldForceScrollToBottom = useShouldForceScrollToBottom(
60+
messages,
61+
client.userID,
5162
);
52-
const mounted = useRef(false);
53-
const atBottom = useRef(false);
54-
const lastMessageId = useRef('');
55-
56-
useEffect(() => {
57-
/* handle scrolling behavior for new messages */
58-
if (!messages?.length) return;
59-
60-
const lastMessage = messages[messages.length - 1];
61-
const prevMessageId = lastMessageId.current;
62-
lastMessageId.current = lastMessage.id || ''; // update last message id
63-
64-
/* do nothing if new messages are loaded from top(loadMore) */
65-
if (lastMessage.id === prevMessageId) return;
66-
67-
/* if list is already at the bottom return, followOutput will do the job */
68-
if (atBottom.current) return;
69-
70-
/* if the new message belongs to current user scroll to bottom */
71-
if (lastMessage.user?.id === client.userID) {
72-
setTimeout(() => virtuoso.current?.scrollToIndex(messages.length));
73-
return;
74-
}
75-
76-
/* otherwise just show newMessage notification */
77-
setNewMessagesNotification(true);
78-
}, [client.userID, messages]);
79-
80-
useEffect(() => {
81-
/*
82-
* scroll to bottom when list is rendered for the first time
83-
* this is due to initialTopMostItemIndex buggy behavior leading to empty screen
84-
*/
85-
if (mounted.current) return;
86-
mounted.current = true;
87-
if (messages?.length && virtuoso.current) {
88-
virtuoso.current.scrollToIndex(messages.length - 1);
89-
}
90-
}, [messages?.length]);
9163

9264
const messageRenderer = useCallback(
93-
(messageList, i) => {
65+
(messageList, virtuosoIndex) => {
66+
const streamMessageIndex =
67+
virtuosoIndex + numItemsPrepended - PREPEND_OFFSET;
9468
// use custom renderer supplied by client if present and skip the rest
95-
if (customMessageRenderer) return customMessageRenderer(messageList, i);
69+
if (customMessageRenderer) {
70+
return customMessageRenderer(messageList, streamMessageIndex);
71+
}
72+
73+
const message = messageList[streamMessageIndex];
9674

97-
const message = messageList[i];
9875
if (!message) return <div style={{ height: '1px' }}></div>; // returning null or zero height breaks the virtuoso
9976

100-
if (message.type === 'channel.event' || message.type === 'system')
77+
if (message.type === 'channel.event' || message.type === 'system') {
10178
return <MessageSystem message={message} />;
79+
}
10280

103-
if (message.deleted_at)
81+
if (message.deleted_at) {
10482
return smartRender(MessageDeleted, { message }, null);
83+
}
10584

10685
return (
10786
<Message
10887
message={message}
10988
groupedByUser={
11089
shouldGroupByUser &&
111-
i > 0 &&
112-
message.user.id === messageList[i - 1].user.id
90+
streamMessageIndex > 0 &&
91+
message.user.id === messageList[streamMessageIndex - 1].user.id
11392
}
11493
/>
11594
);
11695
},
117-
[MessageDeleted, customMessageRenderer, shouldGroupByUser],
96+
[
97+
MessageDeleted,
98+
customMessageRenderer,
99+
shouldGroupByUser,
100+
numItemsPrepended,
101+
],
118102
);
119103

104+
const virtuosoComponents = useMemo(() => {
105+
const EmptyPlaceholder = () => <EmptyStateIndicator listType="message" />;
106+
const Header = () =>
107+
loadingMore ? (
108+
<div className="str-chat__virtual-list__loading">
109+
<LoadingIndicator size={20} />
110+
</div>
111+
) : (
112+
<></>
113+
);
114+
115+
/**
116+
* using 'display: inline-block' traps CSS margins of the item elements, preventing incorrect item measurements.
117+
* @type {import('react-virtuoso').Components['Item']}
118+
*/
119+
const Item = (props) => {
120+
return (
121+
<div
122+
{...props}
123+
style={{
124+
display: 'inline-block',
125+
width: '100%',
126+
}}
127+
/>
128+
);
129+
};
130+
131+
const Footer = () => {
132+
return TypingIndicator ? <TypingIndicator avatarSize={24} /> : <></>;
133+
};
134+
135+
return {
136+
EmptyPlaceholder,
137+
Header,
138+
Footer,
139+
Item,
140+
};
141+
}, [EmptyStateIndicator, loadingMore, TypingIndicator]);
142+
120143
if (!messages) {
121144
return null;
122145
}
@@ -128,44 +151,45 @@ const VirtualizedMessageList = ({
128151
ref={virtuoso}
129152
totalCount={messages.length}
130153
overscan={overscan}
131-
followOutput={true}
132-
maxHeightCacheSize={2000} // reset the cache once it reaches 2k
133-
scrollSeek={scrollSeekPlaceHolder}
134-
item={(i) => messageRenderer(messages, i)}
135-
emptyComponent={() => <EmptyStateIndicator listType="message" />}
136-
header={() =>
137-
loadingMore ? (
138-
<div className="str-chat__virtual-list__loading">
139-
<LoadingIndicator size={20} />
140-
</div>
141-
) : (
142-
<></>
143-
)
144-
}
145-
footer={() =>
146-
TypingIndicator ? <TypingIndicator avatarSize={24} /> : <></>
147-
}
154+
style={{ overflowX: 'hidden' }}
155+
followOutput={(isAtBottom) => {
156+
if (shouldForceScrollToBottom()) {
157+
return isAtBottom ? stickToBottomScrollBehavior : 'auto';
158+
}
159+
// a message from another user has been received - don't scroll to bottom unless already there
160+
return isAtBottom ? stickToBottomScrollBehavior : false;
161+
}}
162+
itemContent={(i) => {
163+
return messageRenderer(messages, i);
164+
}}
165+
components={virtuosoComponents}
166+
firstItemIndex={PREPEND_OFFSET - numItemsPrepended}
148167
startReached={() => {
149-
// mounted.current prevents immediate loadMore on first render
150-
if (mounted.current && hasMore) {
151-
loadMore(messageLimit).then(
152-
virtuoso.current?.adjustForPrependedItems,
153-
);
168+
if (hasMore) {
169+
loadMore(messageLimit);
154170
}
155171
}}
172+
initialTopMostItemIndex={
173+
messages && messages.length > 0 ? messages.length - 1 : 0
174+
}
156175
atBottomStateChange={(isAtBottom) => {
157176
atBottom.current = isAtBottom;
158-
if (isAtBottom && newMessagesNotification)
177+
if (isAtBottom && newMessagesNotification) {
159178
setNewMessagesNotification(false);
179+
}
160180
}}
181+
{...(scrollSeekPlaceHolder
182+
? { scrollSeek: scrollSeekPlaceHolder }
183+
: {})}
161184
/>
162185

163186
<div className="str-chat__list-notifications">
164187
<MessageNotification
165188
showNotification={newMessagesNotification}
166189
onClick={() => {
167-
if (virtuoso.current)
168-
virtuoso.current.scrollToIndex(messages.length);
190+
if (virtuoso.current) {
191+
virtuoso.current.scrollToIndex(messages.length - 1);
192+
}
169193
setNewMessagesNotification(false);
170194
}}
171195
>
@@ -186,7 +210,7 @@ export default function VirtualizedMessageListWithContext(props) {
186210
return (
187211
<ChannelContext.Consumer>
188212
{(
189-
/* {Required<Pick<import('types').ChannelContextValue, 'client' | 'messages' | 'loadMore' | 'hasMore' | 'loadingMore'>>} */ context,
213+
/* {Required<Pick<import('types').ChannelContextValue, 'client' | 'messages' | 'loadMore' | 'hasMore'>>} */ context,
190214
) => (
191215
<VirtualizedMessageList
192216
client={context.client}

src/components/MessageList/__tests__/VirtualizedMessageList.test.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
} from '../../../mock-builders';
1515

1616
import VirtualizedMessageList from '../VirtualizedMessageList';
17+
import { usePrependedMessagesCount } from '../hooks/usePrependMessagesCount';
18+
1719
import { Chat } from '../../Chat';
1820
import { Channel } from '../../Channel';
1921

@@ -25,10 +27,9 @@ jest.mock('react-virtuoso', () => {
2527
<Virtuoso
2628
ref={ref}
2729
{...props}
28-
initialItemCount={20}
2930
overscan={0}
3031
initialTopMostItemIndex={0}
31-
itemHeight={30}
32+
fixedItemHeight={30}
3233
/>
3334
)),
3435
};
@@ -77,16 +78,72 @@ describe('VirtualizedMessageList', () => {
7778
it('should render the list without any message', async () => {
7879
const { client, channel } = await createChannel(true);
7980
let tree;
81+
82+
function createNodeMock(element) {
83+
if (element.type === 'div') {
84+
return {
85+
addEventListener() {},
86+
};
87+
}
88+
return null;
89+
}
90+
8091
await renderer.act(async () => {
8192
tree = await renderer.create(
8293
<Chat client={client}>
8394
<Channel channel={channel}>
8495
<VirtualizedMessageList />
8596
</Channel>
8697
</Chat>,
98+
{
99+
createNodeMock,
100+
},
87101
);
88102
});
89103

90104
expect(tree.toJSON()).toMatchSnapshot();
91105
});
92106
});
107+
108+
describe('usePrependedMessagesCount', () => {
109+
const TestCase = ({ messages }) => {
110+
const prependCount = usePrependedMessagesCount(messages);
111+
return <div>{prependCount}</div>;
112+
};
113+
114+
it('calculates the prepended messages using the id prop', async () => {
115+
const render = await renderer.create(<TestCase messages={[]} />);
116+
const expectPrependCount = (count) => {
117+
expect(render.root.findByType('div').props.children).toStrictEqual(count);
118+
};
119+
120+
expectPrependCount(0);
121+
122+
await renderer.act(async () => {
123+
await render.update(<TestCase messages={[{ id: 'a' }]} />);
124+
expectPrependCount(0);
125+
});
126+
127+
await renderer.act(async () => {
128+
await render.update(
129+
<TestCase messages={[{ id: 'c' }, { id: 'b' }, { id: 'a' }]} />,
130+
);
131+
expectPrependCount(2);
132+
});
133+
134+
await renderer.act(async () => {
135+
await render.update(
136+
<TestCase
137+
messages={[
138+
{ id: 'e' },
139+
{ id: 'd' },
140+
{ id: 'c' },
141+
{ id: 'b' },
142+
{ id: 'a' },
143+
]}
144+
/>,
145+
);
146+
expectPrependCount(4);
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)