Skip to content

Commit 0e23887

Browse files
committed
fix(VML): prevent recalculation of prepended items when messages are swapped due to status change (#2203)
(cherry picked from commit 565e2f9)
1 parent 422096f commit 0e23887

File tree

2 files changed

+187
-34
lines changed

2 files changed

+187
-34
lines changed

src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import type { StreamMessage } from '../../../../context/ChannelStateContext';
44

55
import type { DefaultStreamChatGenerics } from '../../../../types/types';
66

7-
const STATUSES_EXCLUDED_FROM_PREPEND = ['sending', 'failed'];
7+
const STATUSES_EXCLUDED_FROM_PREPEND = ({
8+
failed: true,
9+
sending: true,
10+
} as const) as Record<string, boolean>;
811

912
export function usePrependedMessagesCount<
1013
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
1114
>(messages: StreamMessage<StreamChatGenerics>[], hasDateSeparator: boolean) {
1215
const firstRealMessageIndex = hasDateSeparator ? 1 : 0;
13-
const firstMessageId = useRef<string>();
14-
const earliestMessageId = useRef<string>();
16+
const firstMessageOnFirstLoadedPage = useRef<StreamMessage<StreamChatGenerics>>();
17+
const previousFirstMessageOnFirstLoadedPage = useRef<StreamMessage<StreamChatGenerics>>();
1518
const previousNumItemsPrepended = useRef(0);
1619

1720
const numItemsPrepended = useMemo(() => {
@@ -20,51 +23,56 @@ export function usePrependedMessagesCount<
2023
return 0;
2124
}
2225

23-
const currentFirstMessageId = messages?.[firstRealMessageIndex]?.id;
24-
// if no new messages were prepended, return early (same amount as before)
25-
if (currentFirstMessageId === earliestMessageId.current) {
26+
const currentFirstMessage = messages?.[firstRealMessageIndex];
27+
28+
const noNewMessages =
29+
currentFirstMessage?.id === previousFirstMessageOnFirstLoadedPage.current?.id;
30+
31+
// This is possible only, when sending messages very quickly (basically single char messages submitted like a crazy) in empty channel (first page)
32+
// Optimistic UI update, when sending messages, can lead to a situation, when
33+
// the order of the messages changes for a moment. This can happen, when a user
34+
// sends multiple messages withing few milliseconds. E.g. we send a message A
35+
// then message B. At first we have message array with both messages of status "sending"
36+
// then response for message A is received with a new - later - created_at timestamp
37+
// this leads to rearrangement of 1.B ("sending"), 2.A ("received"). Still firstMessageOnFirstLoadedPage.current
38+
// points to message A, but now this message has index 1 => previousNumItemsPrepended.current === 1
39+
// That in turn leads to incorrect index calculation in VirtualizedMessageList trying to access a message
40+
// at non-existent index. Therefore, we ignore messages of status "sending" / "failed" in order they are
41+
// not considered as prepended messages.
42+
const firstMsgMovedAfterMessagesInExcludedStatus =
43+
currentFirstMessage?.status && STATUSES_EXCLUDED_FROM_PREPEND[currentFirstMessage.status];
44+
45+
if (noNewMessages || firstMsgMovedAfterMessagesInExcludedStatus) {
2646
return previousNumItemsPrepended.current;
2747
}
2848

29-
if (!firstMessageId.current) {
30-
firstMessageId.current = currentFirstMessageId;
49+
if (!firstMessageOnFirstLoadedPage.current) {
50+
firstMessageOnFirstLoadedPage.current = currentFirstMessage;
3151
}
32-
earliestMessageId.current = currentFirstMessageId;
52+
previousFirstMessageOnFirstLoadedPage.current = currentFirstMessage;
3353
// if new messages were prepended, find out how many
3454
// start with this number because there cannot be fewer prepended items than before
35-
let adjustPrependedMessageCount = 0;
36-
for (let i = previousNumItemsPrepended.current; i < messages.length; i += 1) {
37-
// Optimistic UI update, when sending messages, can lead to a situation, when
38-
// the order of the messages changes for a moment. This can happen, when a user
39-
// sends multiple messages withing few milliseconds. E.g. we send a message A
40-
// then message B. At first we have message array with both messages of status "sending"
41-
// then response for message A is received with a new - later - created_at timestamp
42-
// this leads to rearrangement of 1.B ("sending"), 2.A ("received"). Still firstMessageId.current
43-
// points to message A, but now this message has index 1 => previousNumItemsPrepended.current === 1
44-
// That in turn leads to incorrect index calculation in VirtualizedMessageList trying to access a message
45-
// at non-existent index. Therefore, we ignore messages of status "sending" / "failed" in order they are
46-
// not considered as prepended messages.
47-
if (
48-
messages[i]?.status &&
49-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
50-
STATUSES_EXCLUDED_FROM_PREPEND.includes(messages[i].status!) &&
51-
messages[i].id !== firstMessageId.current
52-
) {
53-
adjustPrependedMessageCount++;
54-
}
55-
if (messages[i].id === firstMessageId.current) {
56-
previousNumItemsPrepended.current = i - adjustPrependedMessageCount;
57-
return previousNumItemsPrepended.current;
55+
for (
56+
let prependedMessageCount = previousNumItemsPrepended.current;
57+
prependedMessageCount < messages.length;
58+
prependedMessageCount += 1
59+
) {
60+
const messageIsFirstOnFirstLoadedPage =
61+
messages[prependedMessageCount].id === firstMessageOnFirstLoadedPage.current?.id;
62+
63+
if (messageIsFirstOnFirstLoadedPage) {
64+
previousNumItemsPrepended.current = prependedMessageCount;
65+
return prependedMessageCount;
5866
}
5967
}
6068

6169
// if no match has found, we have jumped - reset the prepended item count.
62-
firstMessageId.current = currentFirstMessageId;
70+
firstMessageOnFirstLoadedPage.current = currentFirstMessage;
6371
previousNumItemsPrepended.current = 0;
6472
return 0;
6573
// TODO: there's a bug here, the messages prop is the same array instance (something mutates it)
6674
// that's why the second dependency is necessary
67-
}, [messages, messages?.length]);
75+
}, [firstRealMessageIndex, messages, messages?.length]);
6876

6977
return numItemsPrepended;
7078
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { usePrependedMessagesCount } from '../VirtualizedMessageList';
3+
import { generateMessage } from '../../../../mock-builders';
4+
5+
const defaultMsgStatus = 'received';
6+
const getMessages = (length, statuses = []) =>
7+
Array.from({ length }, (_, i) =>
8+
generateMessage({
9+
status: statuses?.length ? statuses[i] || defaultMsgStatus : defaultMsgStatus,
10+
}),
11+
);
12+
13+
const messagesWithDateSeparator = ({ length, messages } = {}) => [
14+
generateMessage({ customType: 'message.date' }),
15+
...(messages || getMessages(length)),
16+
];
17+
18+
const hasDateSeparator = true;
19+
const page1 = getMessages(5);
20+
const page2 = getMessages(10);
21+
const page1WithDateSeparator = messagesWithDateSeparator({ messages: page1 });
22+
const page2WithDateSeparator = messagesWithDateSeparator({ messages: page2 });
23+
24+
const render = ({ hasDateSeparator, messages } = {}) =>
25+
renderHook((props) => usePrependedMessagesCount(props.messages, props.hasDateSeparator), {
26+
initialProps: { hasDateSeparator, messages },
27+
});
28+
describe('usePrependMessagesCount', function () {
29+
it('is 0 when not messages are available', () => {
30+
const { result: resultMessagesUndefined } = render();
31+
const { result: resultMessagesUndefinedHasDateSeparator } = render({ hasDateSeparator: true });
32+
const { result: resultMessagesEmpty } = render({ messages: [] });
33+
const { result: resultMessagesEmptyHasDateSeparator } = render({
34+
hasDateSeparator: true,
35+
messages: [],
36+
});
37+
expect(resultMessagesUndefinedHasDateSeparator.current).toBe(0);
38+
expect(resultMessagesUndefined.current).toBe(0);
39+
expect(resultMessagesEmpty.current).toBe(0);
40+
expect(resultMessagesEmptyHasDateSeparator.current).toBe(0);
41+
});
42+
43+
it('is 0 on first loaded page and date separator disabled', () => {
44+
const { result } = render({ messages: page1 });
45+
expect(result.current).toBe(0);
46+
});
47+
48+
it('is 1 on first loaded page and date separator enabled', () => {
49+
const { result } = render({
50+
hasDateSeparator,
51+
messages: page1,
52+
});
53+
expect(result.current).toBe(1);
54+
});
55+
56+
it('is 0 when re-rendered with no new messages and date separator disabled', () => {
57+
const props = {
58+
messages: page1,
59+
};
60+
const { rerender, result } = render(props);
61+
rerender(props);
62+
expect(result.current).toBe(0);
63+
});
64+
65+
it('is 1 when re-rendered with no new messages and date separator enabled', () => {
66+
const props = {
67+
hasDateSeparator,
68+
messages: page1,
69+
};
70+
const { rerender, result } = render(props);
71+
rerender(props);
72+
expect(result.current).toBe(1);
73+
});
74+
75+
it('is 0 when re-rendered with no new but swapped messages and date separator disabled', () => {
76+
const messages = getMessages(2, ['sending', 'sending']);
77+
const { rerender, result } = render({
78+
messages,
79+
});
80+
81+
rerender({ messages: [messages[1], { ...messages[0], status: 'received' }] });
82+
expect(result.current).toBe(0);
83+
});
84+
85+
it('is 0 when re-rendered with no new but swapped messages and date separator enabled', () => {
86+
const messages = getMessages(2, ['sending', 'sending']);
87+
const messages1 = messagesWithDateSeparator({
88+
messages,
89+
});
90+
const messages2 = messagesWithDateSeparator({
91+
messages: [
92+
messages[1],
93+
...getMessages(1, ['sending']),
94+
{ ...messages[0], status: 'received' },
95+
],
96+
});
97+
const { rerender, result } = render({
98+
hasDateSeparator,
99+
messages: messages1,
100+
});
101+
rerender({
102+
hasDateSeparator,
103+
messages: messages2,
104+
});
105+
106+
expect(result.current).toBe(0);
107+
});
108+
109+
it('is equal to newly loaded page size when date separator disabled', () => {
110+
const { rerender, result } = render({
111+
messages: page1,
112+
});
113+
rerender({ messages: [...page2, ...page1] });
114+
expect(result.current).toBe(page2.length);
115+
});
116+
117+
it('is equal to newly loaded page size + 1 when date separator enabled', () => {
118+
const { rerender, result } = render({
119+
hasDateSeparator,
120+
messages: page1WithDateSeparator,
121+
});
122+
rerender({
123+
hasDateSeparator,
124+
messages: messagesWithDateSeparator({ messages: [...page2, ...page1] }),
125+
});
126+
expect(result.current).toBe(page2.length + 1);
127+
});
128+
129+
it('is 0 when jumped to a message and date separator disabled', () => {
130+
const { rerender, result } = render({
131+
messages: page1,
132+
});
133+
rerender({ messages: page2 });
134+
expect(result.current).toBe(0);
135+
});
136+
137+
it('is 0 when jumped to a message and date separator enabled', () => {
138+
const { rerender, result } = render({
139+
hasDateSeparator,
140+
messages: page1WithDateSeparator,
141+
});
142+
rerender({ hasDateSeparator, messages: page2WithDateSeparator });
143+
expect(result.current).toBe(0);
144+
});
145+
});

0 commit comments

Comments
 (0)