Skip to content

Commit 3685030

Browse files
authored
fix: initial message load issues (#2292)
### 🎯 Goal This PR covers a range of issues that originate from incorrect initialization of message list state. All the cases are covered below. ### πŸ›  Implementation details #### `ChannelList`, `Channel`, `MessageList`, `VirtualizedMessageList` not respecting the message limits We have several default limits listed in the `limit.ts` file. However they were not always respected: 1. `MessageList`, `VirtualizedMessageList` didn't use `DEFAULT_NEXT_CHANNEL_PAGE_SIZE` and defaulted to the hard-coded message limit of 100. 2. `Channel` didn't use `DEFAULT_INITIAL_CHANNEL_PAGE_SIZE` when fetching the first page of messages. 3. `ChannelList` didn't use `DEFAULT_INITIAL_CHANNEL_PAGE_SIZE` as the message limit per channel when fetching channels. This is now fixed. #### False-negative `hasMore` when messages are fetched by `ChannelList` We have two main ways to initialize the message list: it can be initialized by the `Channel` component; or it can be initialized by the `ChannelList` component (using `usePaginatedChannels` hook). If the message list is initialized by the `ChannelList`, the `Channel` component will not try to query the channel, and will use messages fetched by the `ChannelList` instead. The problem here is that to determine if the channel has more messages to load, we used to compare the number of messages currently in the channel with the limit set on the `Channel`. But since it was not the `Channel` but the `ChannelList` that fetched the messages, this check makes no sense: `ChannelList` has its own separate limits. Consider this example: ```tsx const App = () => ( <Chat client={chatClient}> <ChannelList options={{ message_limit: 5 }} /> <Channel channelQueryOptions={{ members: { limit: 10 } }}> {/* ... */} </Channel> </Chat> ); ``` The `Channel` component will compare the number of messages fetched by the `ChannelList` (5) with its own limit (10) and determine that the channel has no more messages. This is fixed by always setting `hasMore` to false in channel if it was not the `Channel` component that fetched the messages. In the worst case we'll just make one redundant query. #### The "tall window" problem In case the message container is very tall, or the message limit for the initial load is very small, we can run into a situation when the first page of messages is not long enough to trigger overflow in the message list. Without overflow, scroll events don't fire, so the next page of messages is never fetched. This is not usually a problem in the `VirtualizedMessageList`, since it will immediately load the next page _once_ in this case. However, the `MessageList` didn't have this logic in place. It is now added (by invoking the `scrollListener` _once_ on mount). **Note:** both `VirtualizedMessageList` and `MessageList` will only try loading the next page _once_ if the first page of messages is not long enough to cause overflow. A better solution would be to keep loading pages until there's overflow or there is no more pages. This solution is not currently possible to implement in `VirtualizedMessageList`, so for the sake of consistency I didn't implement it `MessageList` (event though it is possible). In most cases this not an issue, since our default page size is 100 messages which is enough to fill even very tall containers.
1 parent 652e3a5 commit 3685030

File tree

11 files changed

+61
-22
lines changed

11 files changed

+61
-22
lines changed

β€Ždocusaurus/docs/React/components/core-components/message-list.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ Array of allowed message actions (ex: 'edit', 'delete', 'reply'). To disable all
476476

477477
The limit to use when paginating new messages (the page size).
478478

479+
:::caution
480+
After mounting, the `MessageList` component checks if the list is completely filled with messages. If there is some space left in the list, `MessageList` will load the next page of messages, but it will do so _only once_. This means that if your `messageLimit` is too low, or if your viewport is very large, the list will not be completely filled. Set the limit with this in mind.
481+
:::
482+
479483
| Type | Default |
480484
| ------ | ------- |
481485
| number | 100 |

β€Ždocusaurus/docs/React/components/core-components/virtualized-list.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ Custom UI component to display an individual message.
186186

187187
The limit to use when paginating messages (the page size).
188188

189+
:::caution
190+
After mounting, the `VirtualizedMessageList` component checks if the list is completely filled with messages. If there is some space left in the list, `VirtualizedMessageList` will load the next page of messages, but it will do so _only once_. This means that if your `messageLimit` is too low, or if your viewport is very large, the list will not be completely filled. Set the limit with this in mind.
191+
:::
192+
189193
| Type | Default |
190194
| ------ | ------- |
191195
| number | 100 |

β€Žexamples/typescript/src/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as serviceWorker from './serviceWorker';
1010
createRoot(document.getElementById('root')!).render(
1111
<React.StrictMode>
1212
<App />
13-
</React.StrictMode>
13+
</React.StrictMode>,
1414
);
1515

1616
// If you want your app to work offline and load faster, you can change

β€Žpackage.json

+2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"isomorphic-ws": "^4.0.1",
7272
"linkifyjs": "^4.1.0",
7373
"lodash.debounce": "^4.0.8",
74+
"lodash.defaultsdeep": "^4.6.1",
7475
"lodash.throttle": "^4.1.1",
7576
"lodash.uniqby": "^4.7.0",
7677
"nanoid": "^3.3.4",
@@ -155,6 +156,7 @@
155156
"@types/jsdom": "^21.1.5",
156157
"@types/linkifyjs": "^2.1.3",
157158
"@types/lodash.debounce": "^4.0.7",
159+
"@types/lodash.defaultsdeep": "^4.6.9",
158160
"@types/lodash.throttle": "^4.1.7",
159161
"@types/lodash.uniqby": "^4.7.7",
160162
"@types/moment": "^2.13.0",

β€Žsrc/components/Channel/Channel.tsx

+20-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React, {
1010
} from 'react';
1111

1212
import debounce from 'lodash.debounce';
13+
import defaultsDeep from 'lodash.defaultsdeep';
1314
import throttle from 'lodash.throttle';
1415
import {
1516
ChannelAPIResponse,
@@ -346,7 +347,7 @@ const ChannelInner = <
346347
acceptedFiles,
347348
activeUnreadHandler,
348349
channel,
349-
channelQueryOptions,
350+
channelQueryOptions: propChannelQueryOptions,
350351
children,
351352
doDeleteMessageRequest,
352353
doMarkReadRequest,
@@ -366,6 +367,16 @@ const ChannelInner = <
366367
skipMessageDataMemoization,
367368
} = props;
368369

370+
const channelQueryOptions: ChannelQueryOptions<StreamChatGenerics> & {
371+
messages: { limit: number };
372+
} = useMemo(
373+
() =>
374+
defaultsDeep(propChannelQueryOptions, {
375+
messages: { limit: DEFAULT_INITIAL_CHANNEL_PAGE_SIZE },
376+
}),
377+
[propChannelQueryOptions],
378+
);
379+
369380
const {
370381
client,
371382
customClasses,
@@ -546,6 +557,7 @@ const ChannelInner = <
546557
useLayoutEffect(() => {
547558
let errored = false;
548559
let done = false;
560+
let channelInitializedExternally = true;
549561

550562
(async () => {
551563
if (!channel.initialized && initializeOnMount) {
@@ -571,6 +583,7 @@ const ChannelInner = <
571583
await getChannel({ channel, client, members, options: channelQueryOptions });
572584
const config = channel.getConfig();
573585
setChannelConfig(config);
586+
channelInitializedExternally = false;
574587
} catch (e) {
575588
dispatch({ error: e as Error, type: 'setError' });
576589
errored = true;
@@ -583,10 +596,12 @@ const ChannelInner = <
583596
if (!errored) {
584597
dispatch({
585598
channel,
586-
hasMore: hasMoreMessagesProbably(
587-
channel.state.messages.length,
588-
channelQueryOptions?.messages?.limit ?? DEFAULT_INITIAL_CHANNEL_PAGE_SIZE,
589-
),
599+
hasMore:
600+
channelInitializedExternally ||
601+
hasMoreMessagesProbably(
602+
channel.state.messages.length,
603+
channelQueryOptions.messages.limit,
604+
),
590605
type: 'initStateFromChannel',
591606
});
592607

β€Žsrc/components/Channel/__tests__/Channel.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ describe('Channel', () => {
290290

291291
await waitFor(() => {
292292
expect(watchSpy).toHaveBeenCalledTimes(1);
293-
expect(watchSpy).toHaveBeenCalledWith(undefined);
293+
expect(watchSpy).toHaveBeenCalledWith({ messages: { limit: 25 } });
294294
});
295295
});
296296

β€Žsrc/components/ChannelList/hooks/usePaginatedChannels.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useChatContext } from '../../../context/ChatContext';
99

1010
import type { DefaultStreamChatGenerics } from '../../../types/types';
1111
import type { ChannelsQueryState } from '../../Chat/hooks/useChannelsQueryState';
12+
import { DEFAULT_INITIAL_CHANNEL_PAGE_SIZE } from '../../../constants/limits';
1213

1314
const RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 5000;
1415
const MIN_RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 2000;
@@ -79,6 +80,7 @@ export const usePaginatedChannels = <
7980

8081
const newOptions = {
8182
limit: options?.limit ?? MAX_QUERY_CHANNELS_LIMIT,
83+
message_limit: options?.message_limit ?? DEFAULT_INITIAL_CHANNEL_PAGE_SIZE,
8284
offset,
8385
...options,
8486
};

β€Žsrc/components/InfiniteScrollPaginator/InfiniteScroll.tsx

+8-12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { PropsWithChildren, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
1+
import React, { PropsWithChildren, useEffect, useLayoutEffect, useRef } from 'react';
22
import type { PaginatorProps } from '../../types/types';
33
import { deprecationAndReplacementWarning } from '../../utils/deprecationWarning';
44

@@ -73,7 +73,8 @@ export const InfiniteScroll = (props: PropsWithChildren<InfiniteScrollProps>) =>
7373

7474
const scrollComponent = useRef<HTMLElement>();
7575

76-
const scrollListener = useCallback(() => {
76+
const scrollListenerRef = useRef<() => void>();
77+
scrollListenerRef.current = () => {
7778
const element = scrollComponent.current;
7879

7980
if (!element || element.offsetParent === null) {
@@ -103,15 +104,7 @@ export const InfiniteScroll = (props: PropsWithChildren<InfiniteScrollProps>) =>
103104
if (offset < Number(threshold) && typeof loadNextPageFn === 'function' && hasNextPageFlag) {
104105
loadNextPageFn();
105106
}
106-
}, [
107-
hasPreviousPageFlag,
108-
hasNextPageFlag,
109-
isLoading,
110-
listenToScroll,
111-
loadPreviousPageFn,
112-
loadNextPageFn,
113-
threshold,
114-
]);
107+
};
115108

116109
useEffect(() => {
117110
deprecationAndReplacementWarning(
@@ -130,14 +123,17 @@ export const InfiniteScroll = (props: PropsWithChildren<InfiniteScrollProps>) =>
130123
const scrollElement = scrollComponent.current?.parentNode;
131124
if (!scrollElement) return;
132125

126+
const scrollListener = () => scrollListenerRef.current?.();
127+
133128
scrollElement.addEventListener('scroll', scrollListener, useCapture);
134129
scrollElement.addEventListener('resize', scrollListener, useCapture);
130+
scrollListener();
135131

136132
return () => {
137133
scrollElement.removeEventListener('scroll', scrollListener, useCapture);
138134
scrollElement.removeEventListener('resize', scrollListener, useCapture);
139135
};
140-
}, [initialLoad, scrollListener, useCapture]);
136+
}, [initialLoad, useCapture]);
141137

142138
useEffect(() => {
143139
const scrollElement = scrollComponent.current?.parentNode;

β€Žsrc/components/MessageList/MessageList.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { MessageProps } from '../Message/types';
3838
import type { StreamMessage } from '../../context/ChannelStateContext';
3939

4040
import type { DefaultStreamChatGenerics } from '../../types/types';
41+
import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE } from '../../constants/limits';
4142

4243
type MessageListWithContextProps<
4344
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -68,7 +69,7 @@ const MessageListWithContext = <
6869
headerPosition,
6970
read,
7071
renderMessages = defaultRenderMessages,
71-
messageLimit = 100,
72+
messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
7273
loadMore: loadMoreCallback,
7374
loadMoreNewer: loadMoreNewerCallback,
7475
hasMoreNewer = false,

β€Žsrc/components/MessageList/VirtualizedMessageList.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { ComponentContextValue, useComponentContext } from '../../context/Compon
5353

5454
import type { Channel, ChannelState as StreamChannelState, UserResponse } from 'stream-chat';
5555
import type { DefaultStreamChatGenerics, UnknownType } from '../../types/types';
56+
import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE } from '../../constants/limits';
5657

5758
type VirtualizedMessageListPropsForContext =
5859
| 'additionalMessageInputProps'
@@ -184,7 +185,7 @@ const VirtualizedMessageListWithContext = <
184185
loadMoreNewer,
185186
Message: MessageUIComponentFromProps,
186187
messageActions,
187-
messageLimit = 100,
188+
messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
188189
messages,
189190
notifications,
190191
// TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component
@@ -386,7 +387,9 @@ const VirtualizedMessageListWithContext = <
386387
}
387388
};
388389
const atTopStateChange = (isAtTop: boolean) => {
389-
if (isAtTop) loadMore?.(messageLimit);
390+
if (isAtTop) {
391+
loadMore?.(messageLimit);
392+
}
390393
};
391394

392395
useEffect(() => {

β€Žyarn.lock

+12
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,13 @@
24972497
dependencies:
24982498
"@types/lodash" "*"
24992499

2500+
"@types/lodash.defaultsdeep@^4.6.9":
2501+
version "4.6.9"
2502+
resolved "https://registry.yarnpkg.com/@types/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.9.tgz#050fbe389a7a6e245b15da9ee83a8a62f047a1c4"
2503+
integrity sha512-pLtCFK0YkHfGtGLYLNMTbFB5/G5+RsmQCIbbHH8GOAXjv+gDkVilY98kILfe8JH2Kev0OCReYxp1AjxEjP8ixA==
2504+
dependencies:
2505+
"@types/lodash" "*"
2506+
25002507
"@types/lodash.throttle@^4.1.7":
25012508
version "4.1.7"
25022509
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58"
@@ -9391,6 +9398,11 @@ lodash.deburr@^4.1.0:
93919398
resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b"
93929399
integrity sha1-3bG7s+8HRYwBd7oH3hRCLLAz/5s=
93939400

9401+
lodash.defaultsdeep@^4.6.1:
9402+
version "4.6.1"
9403+
resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6"
9404+
integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==
9405+
93949406
lodash.escaperegexp@^4.1.2:
93959407
version "4.1.2"
93969408
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"

0 commit comments

Comments
Β (0)