Skip to content

Commit

Permalink
⏬ feat: Optimize Scroll Handling with Intersection Observer (#3564)
Browse files Browse the repository at this point in the history
* ⏬ refactor(ScrollToBottom): use Intersection Observer for efficient scroll handling

* chore: imports, remove debug console
  • Loading branch information
danny-avila authored Aug 6, 2024
1 parent 6879de0 commit c2a79ae
Show file tree
Hide file tree
Showing 2 changed files with 54 additions and 27 deletions.
63 changes: 38 additions & 25 deletions client/src/hooks/Messages/useMessageScrolling.ts
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout>();
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext();
const { conversationId } = conversation ?? {};

const checkIfAtBottom = useCallback(() => {
if (!scrollableRef.current) {
return;
}
const timeoutIdRef = useRef<NodeJS.Timeout>();

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 debouncedSetShowScrollButton = useCallback((value: boolean) => {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
setShowScrollButton(value);
}, 150);
}, []);

useLayoutEffect(() => {
const scrollableElement = scrollableRef.current;
if (!scrollableElement) {
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
return;
}
const timeoutId = setTimeout(checkIfAtBottom, 650);

const observer = new IntersectionObserver(
([entry]) => {
debouncedSetShowScrollButton(!entry.isIntersecting);
},
{ root: scrollableRef.current, threshold: 0.1 },
);

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,
Expand All @@ -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]);
Expand Down
18 changes: 16 additions & 2 deletions client/src/hooks/useScrollToRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>;
};

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()}`);
Expand All @@ -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],
);

Expand Down

0 comments on commit c2a79ae

Please sign in to comment.