From c2a79aee1b6f0e1e10a047fdf82f77b1e007ccb4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 6 Aug 2024 16:18:15 -0400 Subject: [PATCH] =?UTF-8?q?=20=E2=8F=AC=20feat:=20Optimize=20Scroll=20Hand?= =?UTF-8?q?ling=20with=20Intersection=20Observer=20(#3564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⏬ refactor(ScrollToBottom): use Intersection Observer for efficient scroll handling * chore: imports, remove debug console --- .../src/hooks/Messages/useMessageScrolling.ts | 63 +++++++++++-------- client/src/hooks/useScrollToRef.ts | 18 +++++- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/client/src/hooks/Messages/useMessageScrolling.ts b/client/src/hooks/Messages/useMessageScrolling.ts index c2f730fb20..6ccc97a566 100644 --- a/client/src/hooks/Messages/useMessageScrolling.ts +++ b/client/src/hooks/Messages/useMessageScrolling.ts @@ -1,50 +1,63 @@ import { useRecoilValue } from 'recoil'; -import { useLayoutEffect, useState, useRef, useCallback, useEffect } from 'react'; +import { Constants } from 'librechat-data-provider'; +import { useState, useRef, useCallback, useEffect } from 'react'; import type { TMessage } from 'librechat-data-provider'; -import useScrollToRef from '../useScrollToRef'; +import useScrollToRef from '~/hooks/useScrollToRef'; import { useChatContext } from '~/Providers'; import store from '~/store'; export default function useMessageScrolling(messagesTree?: TMessage[] | null) { const autoScroll = useRecoilValue(store.autoScroll); - const timeoutIdRef = useRef(); const scrollableRef = useRef(null); const messagesEndRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext(); const { conversationId } = conversation ?? {}; - const checkIfAtBottom = useCallback(() => { - if (!scrollableRef.current) { + const timeoutIdRef = useRef(); + + const debouncedSetShowScrollButton = useCallback((value: boolean) => { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = setTimeout(() => { + setShowScrollButton(value); + }, 150); + }, []); + + useEffect(() => { + if (!messagesEndRef.current || !scrollableRef.current) { return; } - const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; - const diff = Math.abs(scrollHeight - scrollTop); - const percent = Math.abs(clientHeight - diff) / clientHeight; - const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15; - setShowScrollButton(hasScrollbar); - }, [scrollableRef]); + const observer = new IntersectionObserver( + ([entry]) => { + debouncedSetShowScrollButton(!entry.isIntersecting); + }, + { root: scrollableRef.current, threshold: 0.1 }, + ); - useLayoutEffect(() => { - const scrollableElement = scrollableRef.current; - if (!scrollableElement) { - return; - } - const timeoutId = setTimeout(checkIfAtBottom, 650); + observer.observe(messagesEndRef.current); return () => { - clearTimeout(timeoutId); + observer.disconnect(); + clearTimeout(timeoutIdRef.current); }; - }, [checkIfAtBottom]); + }, [messagesEndRef, scrollableRef, debouncedSetShowScrollButton]); const debouncedHandleScroll = useCallback(() => { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = setTimeout(checkIfAtBottom, 100); - }, [checkIfAtBottom]); + if (messagesEndRef.current && scrollableRef.current) { + const observer = new IntersectionObserver( + ([entry]) => { + debouncedSetShowScrollButton(!entry.isIntersecting); + }, + { root: scrollableRef.current, threshold: 0.1 }, + ); + observer.observe(messagesEndRef.current); + return () => observer.disconnect(); + } + }, [debouncedSetShowScrollButton]); - const scrollCallback = () => setShowScrollButton(false); + const scrollCallback = () => debouncedSetShowScrollButton(false); const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({ targetRef: messagesEndRef, @@ -66,13 +79,13 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { return () => { if (abortScroll) { - scrollToBottom && scrollToBottom?.cancel(); + scrollToBottom && scrollToBottom.cancel(); } }; }, [isSubmitting, messagesTree, scrollToBottom, abortScroll]); useEffect(() => { - if (scrollToBottom && autoScroll && conversationId !== 'new') { + if (scrollToBottom && autoScroll && conversationId !== Constants.NEW_CONVO) { scrollToBottom(); } }, [autoScroll, conversationId, scrollToBottom]); diff --git a/client/src/hooks/useScrollToRef.ts b/client/src/hooks/useScrollToRef.ts index fdc2444fb6..2fed7c41a3 100644 --- a/client/src/hooks/useScrollToRef.ts +++ b/client/src/hooks/useScrollToRef.ts @@ -7,7 +7,21 @@ type TUseScrollToRef = { smoothCallback: () => void; }; -export default function useScrollToRef({ targetRef, callback, smoothCallback }: TUseScrollToRef) { +type ThrottledFunction = (() => void) & { + cancel: () => void; + flush: () => void; +}; + +type ScrollToRefReturn = { + scrollToRef?: ThrottledFunction; + handleSmoothToRef: React.MouseEventHandler; +}; + +export default function useScrollToRef({ + targetRef, + callback, + smoothCallback, +}: TUseScrollToRef): ScrollToRefReturn { const logAndScroll = (behavior: 'instant' | 'smooth', callbackFn: () => void) => { // Debugging: // console.log(`Scrolling with behavior: ${behavior}, Time: ${new Date().toISOString()}`); @@ -17,7 +31,7 @@ export default function useScrollToRef({ targetRef, callback, smoothCallback }: // eslint-disable-next-line react-hooks/exhaustive-deps const scrollToRef = useCallback( - throttle(() => logAndScroll('instant', callback), 250, { leading: true }), + throttle(() => logAndScroll('instant', callback), 145, { leading: true }), [targetRef], );