Skip to content

Commit d6cfa69

Browse files
perf: implement raf coalesced value (#3211)
* perf: implement RAF based coalescing * perf: only cancel the frame on unmount * fix: jsdoc * fix: add isLiveStreaming prop and patch to auto scroll * fix: change hook name * fix: change hook name * fix: pass the isLiveStream var to hook * chore: dummy change * fix: revert dummy change * fix: optionally disable raf coalescing --------- Co-authored-by: Khushal Agarwal <[email protected]>
1 parent d9f820a commit d6cfa69

File tree

4 files changed

+122
-16
lines changed

4 files changed

+122
-16
lines changed

package/src/components/MessageList/MessageList.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ type MessageListPropsWithContext = Pick<
222222
* ```
223223
*/
224224
setFlatListRef?: (ref: FlatListType<LocalMessage> | null) => void;
225+
/**
226+
* If true, the message list will be used in a live-streaming scenario.
227+
* This flag is used to make sure that the auto scroll behaves well, if multiple messages are received.
228+
*
229+
* This flag is experimental and is subject to change. Please test thoroughly before using it.
230+
*
231+
* @experimental
232+
*/
233+
isLiveStreaming?: boolean;
225234
};
226235

227236
/**
@@ -256,6 +265,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
256265
InlineUnreadIndicator,
257266
inverted = true,
258267
isListActive = false,
268+
isLiveStreaming = false,
259269
legacyImageViewerSwipeBehaviour,
260270
loadChannelAroundMessage,
261271
loading,
@@ -313,6 +323,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
313323
*/
314324
const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } =
315325
useMessageList({
326+
isLiveStreaming,
316327
noGroupByUser,
317328
threadList,
318329
});
@@ -336,12 +347,17 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
336347

337348
const minIndexForVisible = Math.min(1, processedMessageList.length);
338349

350+
const autoscrollToTopThreshold = useMemo(
351+
() => (isLiveStreaming ? 64 : autoscrollToRecent ? 10 : undefined),
352+
[autoscrollToRecent, isLiveStreaming],
353+
);
354+
339355
const maintainVisibleContentPosition = useMemo(
340356
() => ({
341-
autoscrollToTopThreshold: autoscrollToRecent ? 10 : undefined,
357+
autoscrollToTopThreshold,
342358
minIndexForVisible,
343359
}),
344-
[autoscrollToRecent, minIndexForVisible],
360+
[autoscrollToTopThreshold, minIndexForVisible],
345361
);
346362

347363
/**
@@ -652,7 +668,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
652668
latestNonCurrentMessageBeforeUpdate?.id === latestCurrentMessageAfterUpdate.id;
653669
// if didMergeMessageSetsWithNoUpdates=false, we got new messages
654670
// so we should scroll to bottom if we are near the bottom already
655-
setAutoscrollToRecent(!didMergeMessageSetsWithNoUpdates);
671+
const shouldForceScrollToRecent =
672+
!didMergeMessageSetsWithNoUpdates ||
673+
processedMessageList.length - messageListLengthBeforeUpdate.current > 0;
674+
675+
setAutoscrollToRecent(shouldForceScrollToRecent);
656676

657677
if (!didMergeMessageSetsWithNoUpdates) {
658678
const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current();
@@ -667,8 +687,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
667687
}, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed
668688
}
669689
}
670-
// eslint-disable-next-line react-hooks/exhaustive-deps
671-
}, [channel, processedMessageList, threadList]);
690+
}, [channel, threadList, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef]);
672691

673692
const goToMessage = useStableCallback(async (messageId: string) => {
674693
const indexOfParentInMessageList = processedMessageList.findIndex(
@@ -1218,7 +1237,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
12181237
onViewableItemsChanged={stableOnViewableItemsChanged}
12191238
ref={refCallback}
12201239
renderItem={renderItem}
1240+
scrollEventThrottle={isLiveStreaming ? 16 : undefined}
12211241
showsVerticalScrollIndicator={false}
1242+
// @ts-expect-error react-native internal
1243+
strictMode={isLiveStreaming}
12221244
style={flatListStyle}
12231245
testID='message-flat-list'
12241246
viewabilityConfig={flatListViewabilityConfig}

package/src/components/MessageList/hooks/useMessageList.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import {
1111
import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext';
1212
import { useThreadContext } from '../../../contexts/threadContext/ThreadContext';
1313

14+
import { useRAFCoalescedValue } from '../../../hooks';
1415
import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators';
1516
import { getGroupStyles } from '../utils/getGroupStyles';
1617

1718
export type UseMessageListParams = {
1819
deletedMessagesVisibilityType?: DeletedMessagesVisibilityType;
1920
noGroupByUser?: boolean;
2021
threadList?: boolean;
22+
isLiveStreaming?: boolean;
2123
};
2224

2325
export type GroupType = string;
@@ -48,7 +50,7 @@ export const shouldIncludeMessageInList = (
4850
};
4951

5052
export const useMessageList = (params: UseMessageListParams) => {
51-
const { noGroupByUser, threadList } = params;
53+
const { noGroupByUser, threadList, isLiveStreaming } = params;
5254
const { client } = useChatContext();
5355
const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext();
5456
const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } =
@@ -110,14 +112,19 @@ export const useMessageList = (params: UseMessageListParams) => {
110112
return newMessageList;
111113
}, [client.userID, deletedMessagesVisibilityType, messageList]);
112114

113-
return {
114-
/** Date separators */
115-
dateSeparatorsRef,
116-
/** Message group styles */
117-
messageGroupStylesRef,
118-
/** Messages enriched with dates/readby/groups and also reversed in order */
119-
processedMessageList,
120-
/** Raw messages from the channel state */
121-
rawMessageList: messageList,
122-
};
115+
const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming);
116+
117+
return useMemo(
118+
() => ({
119+
/** Date separators */
120+
dateSeparatorsRef,
121+
/** Message group styles */
122+
messageGroupStylesRef,
123+
/** Messages enriched with dates/readby/groups and also reversed in order */
124+
processedMessageList: data,
125+
/** Raw messages from the channel state */
126+
rawMessageList: messageList,
127+
}),
128+
[data, messageList],
129+
);
123130
};

package/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './useMessageReminder';
99
export * from './useQueryReminders';
1010
export * from './useClientNotifications';
1111
export * from './useInAppNotificationsState';
12+
export * from './useRAFCoalescedValue';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
/**
4+
* A utility hook that coalesces a fast changing value to the display’s frame rate.
5+
* It accepts any “noisy” input (arrays, objects, numbers, etc.) and exposes a value
6+
* that React consumers will see at most once per animation frame (via
7+
* `requestAnimationFrame`). This is useful when upstream sources (selectors, sockets,
8+
* DB listeners) can fire multiple times within a single paint and you want to avoid
9+
* extra renders and layout churn.
10+
*
11+
* How it works:
12+
* - Keeps track of the latest incoming value
13+
* - Ensures there is **at most one** pending RAF at a time
14+
* - When the RAF fires, commits the **latest** value to state (`emitted`)
15+
* - If additional changes arrive before the RAF runs, they are merged (the last write
16+
* operation wins) and no new RAF is scheduled
17+
*
18+
* With this hook you can:
19+
* - Feed a `FlatList`/`SectionList` from fast changing sources without spamming re-renders
20+
* - Align React updates to the paint cadence (one publish per frame)
21+
* - Help preserve item anchoring logic (e.g., MVCP) by reducing in-frame updates
22+
*
23+
* **Caveats:**
24+
* - This hook intentionally skips intermediate states that occur within the same
25+
* frame. If you must observe every transition (e.g., for analytics/reducers), do that
26+
* upstream; this hook is for visual coalescing
27+
* - Equality checks are simple referential equalities. If your producer recreates arrays
28+
* or objects each time, you’ll still publish once per frame. To avoid even those
29+
* emissions, stabilize upstream
30+
* - This is not a silver bullet for throttle/debounce; it uses the screen’s refresh cycle;
31+
* If you need “no more than once per X ms”, layer that upstream
32+
*
33+
* Usage tips:
34+
* - Prefer passing already-memoized values when possible (e.g., stable arrays by ID).
35+
* - Pair with a stable `keyExtractor` in lists so coalesced updates map cleanly to rows.
36+
* - Do not cancel/reschedule on prop changes; cancellation is handled on unmount only.
37+
*
38+
* @param value The upstream value that may change multiple times within a single frame.
39+
* @param isEnabled Determines whether the hook should be run or not (useful for cases where
40+
* we want to conditionally use RAF when certain feature feature flags are enabled). If `false`,
41+
* it will simply pass the data through (maintaining the reference as well).
42+
* @returns A value that updates **at most once per frame** with the latest input.
43+
*/
44+
export const useRAFCoalescedValue = <S>(value: S, isEnabled: boolean | undefined): S => {
45+
const [emitted, setEmitted] = useState<S>(value);
46+
const pendingRef = useRef<S>(value);
47+
const rafIdRef = useRef<number | null>(null);
48+
49+
// If `value` changes, schedule a single RAF to publish the latest one.
50+
useEffect(() => {
51+
if (value === pendingRef.current || !isEnabled) return;
52+
pendingRef.current = value;
53+
54+
// already scheduled the next frame, skip
55+
if (rafIdRef.current) return;
56+
57+
const run = () => {
58+
rafIdRef.current = null;
59+
setEmitted(pendingRef.current);
60+
};
61+
62+
rafIdRef.current = requestAnimationFrame(run);
63+
}, [value, isEnabled]);
64+
65+
useEffect(() => {
66+
return () => {
67+
// cancel the frame if it exists only on unmount
68+
if (rafIdRef.current) {
69+
cancelAnimationFrame(rafIdRef.current);
70+
rafIdRef.current = null;
71+
}
72+
};
73+
}, []);
74+
75+
return isEnabled ? emitted : value;
76+
};

0 commit comments

Comments
 (0)