@@ -5,17 +5,35 @@ import { useMessageListScrollManager } from './useMessageListScrollManager';
55import type { LocalMessage } from 'stream-chat' ;
66
77export type UseScrollLocationLogicParams = {
8+ /** Disables automatic scroll-to-bottom updates after message changes. */
89 disableAutoScrollToBottom ?: boolean ;
10+ /** Disables scroll-management adjustments (anchor restore, append/prepend handling). */
911 disableScrollManagement ?: boolean ;
12+ /** True when there are newer messages to load beyond the currently rendered page. */
1013 hasMoreNewer : boolean ;
14+ /** Scrollable message-list container element. */
1115 listElement : HTMLDivElement | null ;
16+ /** Threshold used to detect older-page pagination proximity near the top. */
1217 loadMoreScrollThreshold : number ;
18+ /** Indicates whether older-page pagination is currently in progress. */
1319 loadingMore ?: boolean ;
20+ /** Hard-disable all autoscroll behavior. */
1421 suppressAutoscroll : boolean ;
22+ /** Current rendered message set used for scroll reconciliation. */
1523 messages ?: LocalMessage [ ] ;
24+ /** Distance from bottom (px) considered as "near bottom". */
1625 scrolledUpThreshold ?: number ;
1726} ;
1827
28+ /**
29+ * Centralized scroll-position logic for MessageList.
30+ *
31+ * Responsibilities:
32+ * - Keep viewport stable during prepend/append pagination updates.
33+ * - Track whether the list is near bottom and expose that state to UI.
34+ * - Auto-scroll to bottom when appropriate while respecting suppression flags.
35+ * - Perform a short hydration settle pass so freshly loaded lists land at bottom.
36+ */
1937export const useScrollLocationLogic = ( params : UseScrollLocationLogicParams ) => {
2038 const {
2139 disableAutoScrollToBottom = false ,
@@ -41,6 +59,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
4159 const closeToBottom = useRef ( false ) ;
4260 const closeToTop = useRef ( false ) ;
4361 const previousScrollTopRef = useRef ( 0 ) ;
62+ const previousMessagesLengthRef = useRef ( messages . length ) ;
4463 const anchorRestoreCleanupRef = useRef < ( ( ) => void ) | null > ( null ) ;
4564
4665 const captureAnchor = useCallback ( ( ) => {
@@ -225,6 +244,10 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
225244 [ hasMoreNewer , justReachedLatestMessageSet , listElement , suppressAutoscroll ] ,
226245 ) ;
227246
247+ /**
248+ * Keeps wrapper geometry up to date and handles the "reached latest merged set"
249+ * path where existing viewport position must be preserved.
250+ */
228251 useLayoutEffect ( ( ) => {
229252 if ( listElement ) {
230253 setWrapperRect ( listElement . getBoundingClientRect ( ) ) ;
@@ -247,6 +270,85 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
247270 // eslint-disable-next-line react-hooks/exhaustive-deps
248271 } , [ disableAutoScrollToBottom , justReachedLatestMessageSet , listElement , hasMoreNewer ] ) ;
249272
273+ /**
274+ * Short post-render bottom settle. This is intentionally small (immediate + 2 retries)
275+ * to catch late layout updates without keeping the list in a prolonged lock loop.
276+ */
277+ useLayoutEffect ( ( ) => {
278+ if (
279+ ! listElement ||
280+ disableAutoScrollToBottom ||
281+ hasMoreNewer ||
282+ suppressAutoscroll ||
283+ justReachedLatestMessageSet ||
284+ isRestoringOlderAnchorRef . current
285+ ) {
286+ return ;
287+ }
288+
289+ const initialDistanceToBottom =
290+ listElement . scrollHeight - ( listElement . scrollTop + listElement . clientHeight ) ;
291+ const messagesHydrated =
292+ previousMessagesLengthRef . current === 0 && messages . length > 0 ;
293+
294+ if ( initialDistanceToBottom > scrolledUpThreshold && ! messagesHydrated ) {
295+ return ;
296+ }
297+
298+ let keepPinnedToBottom = true ;
299+
300+ const maybeScrollToBottom = ( ) => {
301+ if ( keepPinnedToBottom ) {
302+ scrollToBottom ( ) ;
303+ }
304+ } ;
305+
306+ maybeScrollToBottom ( ) ;
307+ const settleDelays = [ 80 , messagesHydrated ? 260 : 420 , 900 , 1700 ] ;
308+ const settleTimeoutIds = settleDelays . map ( ( delay ) =>
309+ setTimeout ( maybeScrollToBottom , delay ) ,
310+ ) ;
311+
312+ const stopKeepingPinnedToBottom = ( ) => {
313+ keepPinnedToBottom = false ;
314+ } ;
315+
316+ // Any direct user interaction with the scroller disables the temporary
317+ // initial-load pin, so manual scrolling is never force-overridden.
318+ listElement . addEventListener ( 'pointerdown' , stopKeepingPinnedToBottom , {
319+ passive : true ,
320+ } ) ;
321+ listElement . addEventListener ( 'touchstart' , stopKeepingPinnedToBottom , {
322+ passive : true ,
323+ } ) ;
324+ listElement . addEventListener ( 'wheel' , stopKeepingPinnedToBottom , {
325+ passive : true ,
326+ } ) ;
327+ listElement . addEventListener ( 'keydown' , stopKeepingPinnedToBottom ) ;
328+
329+ const pinWindowTimeoutId = setTimeout ( ( ) => {
330+ stopKeepingPinnedToBottom ( ) ;
331+ } , 2200 ) ;
332+
333+ return ( ) => {
334+ settleTimeoutIds . forEach ( clearTimeout ) ;
335+ clearTimeout ( pinWindowTimeoutId ) ;
336+ listElement . removeEventListener ( 'pointerdown' , stopKeepingPinnedToBottom ) ;
337+ listElement . removeEventListener ( 'touchstart' , stopKeepingPinnedToBottom ) ;
338+ listElement . removeEventListener ( 'wheel' , stopKeepingPinnedToBottom ) ;
339+ listElement . removeEventListener ( 'keydown' , stopKeepingPinnedToBottom ) ;
340+ } ;
341+ } , [
342+ disableAutoScrollToBottom ,
343+ hasMoreNewer ,
344+ justReachedLatestMessageSet ,
345+ listElement ,
346+ messages . length ,
347+ scrollToBottom ,
348+ scrolledUpThreshold ,
349+ suppressAutoscroll ,
350+ ] ) ;
351+
250352 const updateScrollTop = useMessageListScrollManager ( {
251353 captureAnchor,
252354 disableScrollManagement : disableScrollManagement || isRestoringOlderAnchorRef . current ,
@@ -276,6 +378,14 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
276378 previousHasMoreNewerRef . current = hasMoreNewer ;
277379 } , [ hasMoreNewer ] ) ;
278380
381+ useLayoutEffect ( ( ) => {
382+ previousMessagesLengthRef . current = messages . length ;
383+ } , [ messages . length ] ) ;
384+
385+ /**
386+ * Updates cached scroll metrics and bottom/top proximity state used by
387+ * notifications, autoscroll decisions, and paginator behavior.
388+ */
279389 const onScroll = useCallback (
280390 ( event : React . UIEvent < HTMLDivElement > ) => {
281391 const element = event . target as HTMLDivElement ;
@@ -286,10 +396,13 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
286396
287397 const offsetHeight = element . offsetHeight ;
288398 const scrollHeight = element . scrollHeight ;
399+ const distanceToBottom = scrollHeight - ( scrollTop + offsetHeight ) ;
400+ const bottomEnterThreshold = Math . max ( Math . floor ( scrolledUpThreshold * 0.6 ) , 24 ) ;
289401
290402 const prevCloseToBottom = closeToBottom . current ;
291- closeToBottom . current =
292- scrollHeight - ( scrollTop + offsetHeight ) < scrolledUpThreshold ;
403+ closeToBottom . current = prevCloseToBottom
404+ ? distanceToBottom < scrolledUpThreshold
405+ : distanceToBottom < bottomEnterThreshold ;
293406 closeToTop . current = scrollTop < scrolledUpThreshold ;
294407
295408 if ( closeToBottom . current ) {
0 commit comments