feat(scroll): implement instant scroll-to-bottom behavior for chat messages (#438)

This commit is contained in:
DigHuang
2026-03-12 17:02:43 +08:00
committed by GitHub
Unverified
parent c0c8701cc3
commit c0a3903377
6 changed files with 143 additions and 25 deletions

View File

@@ -0,0 +1,45 @@
import { useState, useRef, useEffect } from 'react';
/**
* A hook that ensures a loading state remains true for at least a minimum duration (e.g., 1000ms),
* preventing flickering for very fast loading times.
*
* @param isLoading - The actual loading state from the data source
* @param minDurationMs - Minimum duration to show loading (default: 1000)
*/
export function useMinLoading(isLoading: boolean, minDurationMs: number = 500) {
const [showLoading, setShowLoading] = useState(isLoading);
const startTime = useRef<number>(0);
// Guarantee that the loading UI activates immediately without any asynchronous delay
if (isLoading && !showLoading) {
setShowLoading(true);
}
// Record the actual timestamp in an effect to respect React purity rules
useEffect(() => {
if (isLoading && startTime.current === 0) {
startTime.current = Date.now();
}
}, [isLoading]);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (!isLoading && showLoading) {
const elapsed = startTime.current > 0 ? Date.now() - startTime.current : 0;
const remaining = Math.max(0, minDurationMs - elapsed);
timeout = setTimeout(() => {
setShowLoading(false);
startTime.current = 0;
}, remaining);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [isLoading, showLoading, minDurationMs]);
return isLoading || showLoading;
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
/**
* A wrapper around useStickToBottom that ensures the initial scroll
* to bottom happens instantly without any visible animation.
*
* @param resetKey - When this key changes, the scroll position will be reset to bottom instantly.
* Typically this should be the conversation ID.
*/
export function useStickToBottomInstant(resetKey?: string) {
const lastKeyRef = useRef(resetKey);
const hasInitializedRef = useRef(false);
const result = useStickToBottom({
initial: "instant",
resize: "smooth",
});
const { scrollRef } = result;
// Reset initialization when key changes
useEffect(() => {
if (resetKey !== lastKeyRef.current) {
hasInitializedRef.current = false;
lastKeyRef.current = resetKey;
}
}, [resetKey]);
// Scroll to bottom instantly on mount or when key changes
useEffect(() => {
if (hasInitializedRef.current) return;
const scrollElement = scrollRef.current;
if (!scrollElement) return;
// Hide, scroll, reveal pattern to avoid visible animation
scrollElement.style.visibility = "hidden";
// Use double RAF to ensure content is rendered
const frame1 = requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Direct scroll to bottom
scrollElement.scrollTop = scrollElement.scrollHeight;
// Small delay to ensure scroll is applied
setTimeout(() => {
scrollElement.style.visibility = "";
hasInitializedRef.current = true;
}, 0);
});
});
return () => cancelAnimationFrame(frame1);
}, [scrollRef, resetKey]);
return result;
}