Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-scroll-flickering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@darkresearch/mallory-client": patch
---

Fix chat scroll behavior to properly handle auto-scrolling during streaming without interrupting user's ability to browse chat history. The chat now stays scrolled to bottom when new content arrives, but immediately stops auto-scrolling when user scrolls up. Auto-scroll resumes when user scrolls back near the bottom. Fixes scroll button flickering during fast streaming by adding scroll direction detection with threshold.

227 changes: 164 additions & 63 deletions apps/client/hooks/useSmartScroll.ts
Original file line number Diff line number Diff line change
@@ -1,100 +1,201 @@
/**
* useSmartScroll - React Native version of use-stick-to-bottom
* useSmartScroll - Simplified auto-scroll hook for chat applications
*
* A lightweight hook for AI chat applications that automatically scrolls to bottom
* when new content is added, but only if the user is already at the bottom.
* Inspired by stackblitz-labs/use-stick-to-bottom.
* Automatically scrolls to bottom when new content is added, but only if
* the user is already at the bottom. Much simpler than the previous version
* to avoid race conditions and state synchronization issues.
*/

import { useRef, useState, useCallback } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import { ScrollView, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';

interface UseSmartScrollReturn {
scrollViewRef: React.RefObject<ScrollView>;
isAtBottom: boolean;
showScrollButton: boolean;
scrollToBottom: () => Promise<boolean>;
scrollToBottom: () => void;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
handleContentSizeChange: (contentWidth: number, contentHeight: number) => void;
}

const BOTTOM_THRESHOLD = 50;
// Minimum scroll distance to detect direction change (prevents false positives)
const SCROLL_DIRECTION_THRESHOLD = 2;

/**
* Check if scroll position is at bottom
* Pure function - moved outside component for better performance
*/
const checkIfAtBottom = (
layoutHeight: number,
contentHeight: number,
scrollY: number
): boolean => {
if (contentHeight <= layoutHeight) return true;
return (contentHeight - (layoutHeight + scrollY)) <= BOTTOM_THRESHOLD;
};

/**
* Helper to safely clear timeout refs
*/
const clearTimeoutSafe = (ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
};

export const useSmartScroll = (): UseSmartScrollReturn => {
const scrollViewRef = useRef<ScrollView>(null);

// Single state source - no dual ref/state tracking
const [isAtBottom, setIsAtBottom] = useState(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const isScrollingToBottom = useRef(false);

// Threshold for "close enough to bottom" (20px tolerance)
const BOTTOM_THRESHOLD = 20;

const checkIfAtBottom = useCallback((
layoutHeight: number,
contentHeight: number,
scrollY: number
): boolean => {
return layoutHeight + scrollY >= contentHeight - BOTTOM_THRESHOLD;

// Derived state - no separate useState needed
const showScrollButton = !isAtBottom;

// Track if we're in auto-scroll mode (prevents showing button during auto-scroll)
const isAutoScrollingRef = useRef(false);

// Ref to track isAtBottom for use in callbacks (avoids stale closures)
const isAtBottomRef = useRef(true);

// Track last known position to detect content growth
const lastContentHeightRef = useRef<number>(0);

// Track last scroll position to detect scroll direction (for canceling auto-scroll)
const lastScrollYRef = useRef<number>(0);

// Single debounce timeout for scroll detection
const scrollDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Single timeout for auto-scroll completion
const autoScrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Track untracked timeout from handleContentSizeChange
const contentSizeChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Keep ref in sync with state
useEffect(() => {
isAtBottomRef.current = isAtBottom;
}, [isAtBottom]);

// Cleanup timeouts on unmount to prevent memory leaks and React warnings
useEffect(() => {
return () => {
clearTimeoutSafe(scrollDebounceRef);
clearTimeoutSafe(autoScrollTimeoutRef);
clearTimeoutSafe(contentSizeChangeTimeoutRef);
};
}, []);

const scrollToBottom = useCallback((): Promise<boolean> => {
return new Promise((resolve) => {
if (!scrollViewRef.current) {
resolve(false);
return;
}
/**
* Scroll to bottom - simple, no promises needed
* @param animated - whether to animate the scroll (default: true for manual, false for auto)
*/
const scrollToBottom = useCallback((animated: boolean = true) => {
if (!scrollViewRef.current) return;

isScrollingToBottom.current = true;

scrollViewRef.current.scrollToEnd({ animated: true });

Comment thread
vercel[bot] marked this conversation as resolved.
// Reset flag after animation completes (roughly 300ms for React Native default)
setTimeout(() => {
isScrollingToBottom.current = false;
setIsAtBottom(true);
setShowScrollButton(false);
resolve(true);
}, 350);
});
// Clear any pending auto-scroll timeout
clearTimeoutSafe(autoScrollTimeoutRef);

// Mark as auto-scrolling
isAutoScrollingRef.current = true;

// Scroll - animated for manual clicks, instant for streaming updates
scrollViewRef.current.scrollToEnd({ animated });

// Reset auto-scroll flag after scroll completes
autoScrollTimeoutRef.current = setTimeout(() => {
isAutoScrollingRef.current = false;
setIsAtBottom(true);
autoScrollTimeoutRef.current = null;
}, animated ? 300 : 100);
}, []);

/**
* Handle scroll events - update state, detect user vs auto scroll
*/
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
// Ignore scroll events triggered by our own scrollToBottom
if (isScrollingToBottom.current) {
return;
}

const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;

const atBottom = checkIfAtBottom(
layoutMeasurement.height,
contentSize.height,
contentOffset.y
);

console.log('📜 Scroll event:', {
scrollY: contentOffset.y,
layoutHeight: layoutMeasurement.height,
contentHeight: contentSize.height,
atBottom,
calculation: `${layoutMeasurement.height} + ${contentOffset.y} >= ${contentSize.height} - ${BOTTOM_THRESHOLD}`
});
// Detect scroll direction: decreasing scrollY = scrolling up (away from bottom)
// Use threshold to prevent false positives during rapid content updates
const currentScrollY = contentOffset.y;
const scrollDelta = lastScrollYRef.current - currentScrollY;
const scrollingAway = scrollDelta > SCROLL_DIRECTION_THRESHOLD;
lastScrollYRef.current = currentScrollY;

setIsAtBottom(atBottom);
setShowScrollButton(!atBottom);
}, [checkIfAtBottom]);
// If we're auto-scrolling, check if user scrolled away
if (isAutoScrollingRef.current) {
if (atBottom) {
// Reached bottom - auto-scroll complete
setIsAtBottom(true);
} else if (scrollingAway) {
// User is manually scrolling UP - cancel auto-scroll immediately
isAutoScrollingRef.current = false;
setIsAtBottom(false);

// Clear any pending auto-scroll timeout
clearTimeoutSafe(autoScrollTimeoutRef);
}
// If scrolling toward bottom (scrollY increasing), let it continue
return;
}

const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
console.log('📏 Content size changed:', { contentHeight, isAtBottom, isScrollingToBottom: isScrollingToBottom.current });

// Only auto-scroll if user is at bottom (like use-stick-to-bottom behavior)
if (isAtBottom && !isScrollingToBottom.current) {
console.log('🔄 Auto-scrolling to bottom due to content change');
// User is manually scrolling - debounce state updates to avoid jank
clearTimeoutSafe(scrollDebounceRef);

scrollDebounceRef.current = setTimeout(() => {
setIsAtBottom(atBottom);
scrollDebounceRef.current = null;
}, 50); // Short debounce for responsive UI
}, []);

/**
* Handle content size changes - trigger auto-scroll if needed
* This is the main trigger for auto-scrolling during streaming
*/
const handleContentSizeChange = useCallback((_contentWidth: number, contentHeight: number) => {
const contentGrew = contentHeight > lastContentHeightRef.current;
lastContentHeightRef.current = contentHeight;

// If we're already auto-scrolling, keep scrolling (for streaming updates)
if (isAutoScrollingRef.current) {
if (scrollViewRef.current) {
// Instant scroll for rapid updates during streaming
scrollViewRef.current.scrollToEnd({ animated: false });

// Extend the auto-scroll timeout since new content arrived
clearTimeoutSafe(autoScrollTimeoutRef);
autoScrollTimeoutRef.current = setTimeout(() => {
isAutoScrollingRef.current = false;
setIsAtBottom(true);
autoScrollTimeoutRef.current = null;
}, 200); // Shorter timeout for streaming updates
}
return;
}

// If content grew and user was at bottom, auto-scroll (without animation for smooth streaming)
// Use ref to avoid stale closure issues
if (contentGrew && isAtBottomRef.current) {
// Small delay to ensure layout is complete
setTimeout(() => {
scrollToBottom();
}, 50);
} else {
console.log('❌ Not auto-scrolling:', { isAtBottom, isScrollingToBottom: isScrollingToBottom.current });
clearTimeoutSafe(contentSizeChangeTimeoutRef);
contentSizeChangeTimeoutRef.current = setTimeout(() => {
if (isAtBottomRef.current && !isAutoScrollingRef.current) {
scrollToBottom(false); // No animation for auto-scroll during streaming
}
contentSizeChangeTimeoutRef.current = null;
}, 10);
}
}, [isAtBottom, scrollToBottom]);
}, [scrollToBottom]);

return {
scrollViewRef: scrollViewRef as React.RefObject<ScrollView>,
Expand Down