fix: Prevent infinite scroll refresh by stabilizing intersection observer - Add trigger prevention mechanism to avoid multiple API calls - Use refs to store current values and avoid stale closures - Remove observer dependencies to prevent recreation - Add proper timeout cleanup to prevent memory leaks

This commit is contained in:
Geert Rademakes 2025-08-06 10:13:21 +02:00
parent 2e32a3c3b6
commit 4f16c859db
2 changed files with 48 additions and 6 deletions

View File

@ -121,6 +121,20 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null); const loadingRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const isTriggeringRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Store current values in refs to avoid stale closures
const hasMoreRef = useRef(hasMore);
const loadingRef_state = useRef(loading);
const onLoadMoreRef = useRef(onLoadMore);
// Update refs when props change
useEffect(() => {
hasMoreRef.current = hasMore;
loadingRef_state.current = loading;
onLoadMoreRef.current = onLoadMore;
}, [hasMore, loading, onLoadMore]);
// Debounce search to prevent excessive API calls // Debounce search to prevent excessive API calls
const debouncedSearchQuery = useDebounce(localSearchQuery, 300); const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
@ -230,8 +244,14 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
if (loadingRef.current) { if (loadingRef.current) {
observerRef.current = new IntersectionObserver( observerRef.current = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && hasMore && !loading) { // Use current values from refs to avoid stale closure
onLoadMore(); if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef_state.current && !isTriggeringRef.current) {
isTriggeringRef.current = true;
onLoadMoreRef.current();
// Reset the flag after a short delay to prevent multiple triggers
timeoutRef.current = setTimeout(() => {
isTriggeringRef.current = false;
}, 1000);
} }
}, },
{ {
@ -247,8 +267,11 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
if (observerRef.current) { if (observerRef.current) {
observerRef.current.disconnect(); observerRef.current.disconnect();
} }
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}; };
}, [hasMore, loading, onLoadMore]); }, []); // Remove dependencies to prevent recreation
return ( return (
<Flex direction="column" height="100%"> <Flex direction="column" height="100%">
@ -298,6 +321,11 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</Checkbox> </Checkbox>
<Text color="gray.400" fontSize="sm"> <Text color="gray.400" fontSize="sm">
{songs.length} of {totalSongs} songs {totalDuration} {songs.length} of {totalSongs} songs {totalDuration}
{hasMore && songs.length > 0 && (
<Text as="span" color="blue.400" ml={2}>
Scroll for more
</Text>
)}
</Text> </Text>
</HStack> </HStack>
@ -354,14 +382,26 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
{/* Loading indicator for infinite scroll */} {/* Loading indicator for infinite scroll */}
{loading && ( {loading && (
<Flex justify="center" p={4} key="loading-spinner"> <Flex justify="center" align="center" p={6} key="loading-spinner" gap={3}>
<Spinner size="md" color="blue.400" /> <Spinner size="md" color="blue.400" />
<Text color="gray.400" fontSize="sm">
Loading more songs...
</Text>
</Flex> </Flex>
)} )}
{/* Intersection observer target */} {/* Intersection observer target */}
<div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" /> <div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" />
{/* Loading more indicator (subtle) */}
{!loading && hasMore && songs.length > 0 && (
<Flex justify="center" p={3} key="loading-more-indicator">
<Text color="gray.600" fontSize="xs">
Scroll for more songs
</Text>
</Flex>
)}
{/* End of results message */} {/* End of results message */}
{!hasMore && songs.length > 0 && ( {!hasMore && songs.length > 0 && (
<Flex justify="center" p={4} key="end-message"> <Flex justify="center" p={4} key="end-message">

View File

@ -100,7 +100,8 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
// Search songs with debouncing // Search songs with debouncing
const searchSongs = useCallback((query: string) => { const searchSongs = useCallback((query: string) => {
setSearchQuery(query); setSearchQuery(query);
// Don't clear songs immediately - let the new search results replace them // Clear songs for new search to replace them
setSongs([]);
setHasMore(true); setHasMore(true);
setCurrentPage(1); setCurrentPage(1);
setError(null); setError(null);
@ -134,7 +135,8 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
if (currentPlaylistRef.current !== playlistName) { if (currentPlaylistRef.current !== playlistName) {
currentPlaylistRef.current = playlistName; currentPlaylistRef.current = playlistName;
if (!isInitialLoad) { if (!isInitialLoad) {
// Don't clear songs immediately - let the new playlist results replace them // Clear songs for new playlist to replace them
setSongs([]);
setHasMore(true); setHasMore(true);
setCurrentPage(1); setCurrentPage(1);
setSearchQuery(initialSearch); setSearchQuery(initialSearch);