Skip to content

Commit e4cf042

Browse files
AhyoungRyuHoonBaek
authored andcommitted
[CLNP-5817][CLNP-5918] fix: scroll & search message issues in GroupChannelProvider (#1263)
Fixes - https://sendbird.atlassian.net/browse/CLNP-5917 - https://sendbird.atlassian.net/browse/CLNP-5918 ### Changes To fix [CLNP-5917](https://sendbird.atlassian.net/browse/CLNP-5917) introduced optimizations to prevent the "Maximum update depth exceeded" error that occurs during message searches: 1. Added useDeepCompareEffect hook: - Performs deep comparison of dependencies instead of reference equality - Particularly useful for handling message array updates efficiently - Inspired by [kentcdodds/use-deep-compare-effect](https://github.com/kentcdodds/use-deep-compare-effect) 2. Enhanced useStore with state change detection: - Added hasStateChanged helper to compare previous and next states - Prevents unnecessary updates when state values haven't actually changed - Optimizes performance by reducing redundant renders 3. Improved storeManager with nested update prevention: - Added protection against nested state updates - Prevents infinite update cycles Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [ ] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) [CLNP-5917]: https://sendbird.atlassian.net/browse/CLNP-5917?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 45d808d commit e4cf042

File tree

4 files changed

+71
-12
lines changed

4 files changed

+71
-12
lines changed

src/hooks/useDeepCompareEffect.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useRef } from 'react';
2+
import isEqual from 'lodash/isEqual';
3+
4+
function useDeepCompareMemoize<T>(value: T): T {
5+
const ref = useRef<T>(value);
6+
7+
if (!isEqual(value, ref.current)) {
8+
ref.current = value;
9+
}
10+
11+
return ref.current;
12+
}
13+
14+
/**
15+
* Custom hook that works like useEffect but performs a deep comparison of dependencies
16+
* instead of reference equality. This is useful when dealing with complex objects or arrays
17+
* in dependencies that could trigger unnecessary re-renders.
18+
*
19+
* Inspired by https://github.com/kentcdodds/use-deep-compare-effect
20+
*
21+
* @param callback Effect callback that can either return nothing (void) or return a cleanup function (() => void).
22+
* @param dependencies Array of dependencies to be deeply compared
23+
*/
24+
function useDeepCompareEffect(
25+
callback: () => void | (() => void),
26+
dependencies: any[],
27+
) {
28+
useEffect(callback, dependencies.map(useDeepCompareMemoize));
29+
}
30+
31+
export default useDeepCompareEffect;

src/hooks/useStore.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useContext, useRef, useCallback } from 'react';
1+
import { useContext, useRef, useCallback, useMemo } from 'react';
22
import { useSyncExternalStore } from 'use-sync-external-store/shim';
3-
import { type Store } from '../utils/storeManager';
3+
import { type Store, hasStateChanged } from '../utils/storeManager';
44

55
type StoreSelector<T, U> = (state: T) => U;
66

@@ -19,6 +19,7 @@ export function useStore<T, U>(
1919
if (!store) {
2020
throw new Error('useStore must be used within a StoreProvider');
2121
}
22+
2223
// Ensure the stability of the selector function using useRef
2324
const selectorRef = useRef(selector);
2425
selectorRef.current = selector;
@@ -36,14 +37,18 @@ export function useStore<T, U>(
3637
);
3738

3839
const updateState = useCallback((updates: Partial<T>) => {
39-
store.setState((prevState) => ({
40-
...prevState,
41-
...updates,
42-
}));
40+
const currentState = store.getState();
41+
42+
if (hasStateChanged(currentState, updates)) {
43+
store.setState((prevState) => ({
44+
...prevState,
45+
...updates,
46+
}));
47+
}
4348
}, [store]);
4449

45-
return {
50+
return useMemo(() => ({
4651
state,
4752
updateState,
48-
};
53+
}), [state, updateState]);
4954
}

src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DependencyList, useLayoutEffect, useRef, useState } from 'react';
22
import pubSubFactory from '../../../../lib/pubSub';
3+
import { useGroupChannel } from './useGroupChannel';
34

45
/**
56
* You can pass the resolve function to scrollPubSub, if you want to catch when the scroll is finished.
@@ -30,7 +31,10 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen
3031
const scrollDistanceFromBottomRef = useRef(0);
3132

3233
const [scrollPubSub] = useState(() => pubSubFactory<ScrollTopics, ScrollTopicUnion>({ publishSynchronous: true }));
33-
const [isScrollBottomReached, setIsScrollBottomReached] = useState(true);
34+
const {
35+
state: { isScrollBottomReached },
36+
actions: { setIsScrollBottomReached },
37+
} = useGroupChannel();
3438

3539
// SideEffect: Reset scroll state
3640
useLayoutEffect(() => {

src/utils/storeManager.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,36 @@ export type Store<T> = {
55
subscribe: (listener: () => void) => () => void;
66
};
77

8+
export function hasStateChanged<T>(prevState: T, updates: Partial<T>): boolean {
9+
return Object.entries(updates).some(([key, value]) => {
10+
return prevState[key as keyof T] !== value;
11+
});
12+
}
13+
814
/**
915
* A custom store creation utility
1016
*/
1117
export function createStore<T extends object>(initialState: T): Store<T> {
1218
let state = { ...initialState };
1319
const listeners = new Set<() => void>();
20+
let isUpdating = false;
1421

1522
const setState = (partial: Partial<T> | ((state: T) => Partial<T>)) => {
16-
const nextState = typeof partial === 'function' ? partial(state) : partial;
17-
state = { ...state, ...nextState };
18-
listeners.forEach((listener) => listener());
23+
// Prevent nested updates
24+
if (isUpdating) return;
25+
26+
try {
27+
isUpdating = true;
28+
const nextState = typeof partial === 'function' ? partial(state) : partial;
29+
const hasChanged = hasStateChanged(state, nextState);
30+
31+
if (hasChanged) {
32+
state = { ...state, ...nextState };
33+
listeners.forEach((listener) => listener());
34+
}
35+
} finally {
36+
isUpdating = false;
37+
}
1938
};
2039

2140
return {

0 commit comments

Comments
 (0)