From 8ac3f2a6f73e44778c93e3dc92d161b02dfd2fe5 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 14 Nov 2025 03:06:44 -0800 Subject: [PATCH] use throttle for item loader, simplify implementation --- .../helpers/item-list-infinite-loader.ts | 294 ++++++------------ .../item-grid-list/item-grid-list.tsx | 32 +- .../item-table-list/item-table-list.tsx | 33 +- 3 files changed, 112 insertions(+), 247 deletions(-) diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index f4e15af01..aeee59d40 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -4,7 +4,8 @@ import { useSuspenseQuery, UseSuspenseQueryOptions, } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import throttle from 'lodash/throttle'; +import { useCallback, useEffect, useMemo } from 'react'; import { queryKeys } from '/@/renderer/api/query-keys'; import { useListContext } from '/@/renderer/context/list-context'; @@ -38,7 +39,6 @@ interface UseItemListInfiniteLoaderProps { itemType: LibraryItem; listCountQuery: UseSuspenseQueryOptions; listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>; - maxPagesToFetch?: number; query: Record; serverId: string; } @@ -57,17 +57,11 @@ export const useItemListInfiniteLoader = ({ itemType, listCountQuery, listQueryFn, - maxPagesToFetch = 2, query = {}, serverId, }: UseItemListInfiniteLoaderProps) => { const queryClient = useQueryClient(); - const scrollStateRef = useRef({ - direction: 'unknown', - lastStartIndex: null, - }); - const { data: totalItemCount } = useSuspenseQuery(listCountQuery); const { setItemCount } = useListContext(); @@ -105,75 +99,44 @@ export const useItemListInfiniteLoader = ({ queryKey: dataQueryKey, }); - const onRangeChanged = useMemo(() => { - return async (range: { startIndex: number; stopIndex: number }) => { - const fetchRange = getFetchRange( - range, - scrollStateRef, - itemsPerPage, - maxPagesToFetch, - fetchThreshold, - ); + const fetchPage = useCallback( + async (pageNumber: number) => { + const startIndex = pageNumber * itemsPerPage; + const queryParams = { + limit: itemsPerPage, + startIndex, + ...query, + }; - // Filter out pages that are already loaded - const pagesToFetch = fetchRange.pagesToFetch.filter( - (pageNumber) => !data.pagesLoaded[pageNumber], - ); + const result = await queryClient.ensureQueryData({ + gcTime: 1000 * 15, + queryFn: async ({ signal }) => { + const result = await listQueryFn({ + apiClientProps: { serverId, signal }, + query: queryParams, + }); - if (pagesToFetch.length === 0) { - return; - } - - // Create fetch promises for all pages - const fetchPromises = pagesToFetch.map(async (pageNumber) => { - const startIndex = pageNumber * itemsPerPage; - const queryParams = { - limit: itemsPerPage, - startIndex, - ...query, - }; - - const result = await queryClient.ensureQueryData({ - gcTime: 1000 * 15, - queryFn: async ({ signal }) => { - const result = await listQueryFn({ - apiClientProps: { serverId, signal }, - query: queryParams, - }); - - return result.items; - }, - queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams), - staleTime: 1000 * 15, - }); - - return { - data: result, - endIndex: startIndex + itemsPerPage, - pageNumber, - startIndex, - }; + return result.items; + }, + queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams), + staleTime: 1000 * 15, }); - // Wait for all pages to be fetched - const pageResults = await Promise.all(fetchPromises); + const endIndex = startIndex + itemsPerPage; - // Update the query data with all fetched pages + // Update the query data with the fetched page queryClient.setQueryData( dataQueryKey, (oldData: { data: unknown[]; pagesLoaded: Record }) => { - let newData = [...oldData.data]; - const newPagesLoaded = { ...oldData.pagesLoaded }; - - // Update data for each fetched page - pageResults.forEach(({ data: pageData, endIndex, pageNumber, startIndex }) => { - newData = [ - ...newData.slice(0, startIndex), - ...pageData, - ...newData.slice(endIndex), - ]; - newPagesLoaded[pageNumber] = true; - }); + const newData = [ + ...oldData.data.slice(0, startIndex), + ...result, + ...oldData.data.slice(endIndex), + ]; + const newPagesLoaded = { + ...oldData.pagesLoaded, + [pageNumber]: true, + }; return { data: newData, @@ -181,19 +144,63 @@ export const useItemListInfiniteLoader = ({ }; }, ); - }; - }, [ - itemsPerPage, - query, - queryClient, - serverId, - dataQueryKey, - listQueryFn, - itemType, - data, - maxPagesToFetch, - fetchThreshold, - ]); + }, + [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType], + ); + + const onRangeChangedBase = useCallback( + async (range: { startIndex: number; stopIndex: number }) => { + const pageNumber = Math.floor(range.startIndex / itemsPerPage); + + const currentData = queryClient.getQueryData<{ + data: unknown[]; + pagesLoaded: Record; + }>(dataQueryKey); + + const startPageBoundary = pageNumber * itemsPerPage; + const endPageBoundary = (pageNumber + 1) * itemsPerPage; + + const distanceFromStartBoundary = range.startIndex - startPageBoundary; + const distanceToEndBoundary = endPageBoundary - range.stopIndex; + + const thresholdDistance = Math.floor(itemsPerPage * fetchThreshold); + + const isCurrentPageLoaded = currentData?.pagesLoaded[pageNumber] ?? false; + + // Fetch current page if not loaded + if (!isCurrentPageLoaded) { + await fetchPage(pageNumber); + } + + // If current page is loaded, check if we should prefetch adjacent pages + if (isCurrentPageLoaded) { + if ( + distanceFromStartBoundary <= thresholdDistance && + pageNumber > 0 && + !currentData?.pagesLoaded[pageNumber - 1] + ) { + await fetchPage(pageNumber - 1); + } + + if ( + distanceToEndBoundary <= thresholdDistance && + !currentData?.pagesLoaded[pageNumber + 1] + ) { + await fetchPage(pageNumber + 1); + } + } + }, + [itemsPerPage, fetchThreshold, queryClient, dataQueryKey, fetchPage], + ); + + const onRangeChanged = useMemo( + () => + throttle(onRangeChangedBase, 150, { + leading: true, + trailing: true, + }), + [onRangeChangedBase], + ); const refresh = useCallback( async (force?: boolean) => { @@ -309,126 +316,3 @@ export const parseListCountQuery = (query: any) => { startIndex: 0, }; }; - -interface ScrollState { - direction: 'down' | 'unknown' | 'up'; - lastStartIndex: null | number; -} - -const getFetchRange = ( - range: { startIndex: number; stopIndex: number }, - scrollState: React.MutableRefObject, - itemsPerPage: number, - maxPagesToFetch: number, - fetchThreshold: number, -) => { - const { lastStartIndex } = scrollState.current; - - // Determine scroll direction - let newDirection: 'down' | 'unknown' | 'up' = scrollState.current.direction; - if (lastStartIndex !== null) { - if (range.startIndex < lastStartIndex) { - newDirection = 'up'; - } else if (range.startIndex > lastStartIndex) { - newDirection = 'down'; - } - } - - scrollState.current = { - direction: newDirection, - lastStartIndex: range.startIndex, - }; - - // Calculate threshold distance - const thresholdDistance = Math.floor(itemsPerPage * fetchThreshold); - - // Determine which pages to fetch based on scroll direction and threshold - let pagesToFetch: number[] = []; - - // Calculate page boundaries for the range - const startPage = Math.floor(range.startIndex / itemsPerPage); - const stopPage = Math.floor(range.stopIndex / itemsPerPage); - - // Distance from startIndex to the start of its page - const distanceFromStartPageTop = range.startIndex - startPage * itemsPerPage; - - // Distance from stopIndex to the end of its page (next page boundary) - const distanceFromStopPageBottom = (stopPage + 1) * itemsPerPage - range.stopIndex; - - if (newDirection === 'down') { - // Always include pages in the visible range - for (let page = startPage; page <= stopPage; page++) { - pagesToFetch.push(page); - } - - // If we're close to the next page boundary below, fetch additional upcoming pages - if (distanceFromStopPageBottom <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - pagesToFetch.push(stopPage + i); - } - } - - // If we're close to a page boundary above, fetch additional previous pages - if (distanceFromStartPageTop <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - const prevPage = startPage - i; - if (prevPage >= 0) { - pagesToFetch.push(prevPage); - } - } - } - } else if (newDirection === 'up') { - // Always include pages in the visible range - for (let page = startPage; page <= stopPage; page++) { - pagesToFetch.push(page); - } - - // If we're close to the previous page boundary above, fetch additional previous pages - if (distanceFromStartPageTop <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - const prevPage = startPage - i; - if (prevPage >= 0) { - pagesToFetch.push(prevPage); - } - } - } - - // If we're close to a page boundary below, fetch additional upcoming pages - if (distanceFromStopPageBottom <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - pagesToFetch.push(stopPage + i); - } - } - } else { - // Unknown direction - fetch pages in the visible range and nearby pages - for (let page = startPage; page <= stopPage; page++) { - pagesToFetch.push(page); - } - - // Fetch additional pages above if close to boundary - if (distanceFromStartPageTop <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - const prevPage = startPage - i; - if (prevPage >= 0) { - pagesToFetch.push(prevPage); - } - } - } - - // Fetch additional pages below if close to boundary - if (distanceFromStopPageBottom <= thresholdDistance && maxPagesToFetch > 1) { - for (let i = 1; i < maxPagesToFetch; i++) { - pagesToFetch.push(stopPage + i); - } - } - } - - // Remove duplicates and filter out negative page numbers - pagesToFetch = [...new Set(pagesToFetch)].filter((page) => page >= 0).sort((a, b) => a - b); - - return { - direction: newDirection, - pagesToFetch, - thresholdDistance, - }; -}; diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 145e4322d..b76b5411c 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -1,6 +1,5 @@ import { useElementSize, useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; -import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; @@ -114,38 +113,23 @@ const VirtualizedGridList = React.memo( itemType, ]); - const debouncedOnScrollEnd = useMemo( - () => - onScrollEnd - ? debounce((scrollOffset: number, direction: 'down' | 'up') => { - onScrollEnd(scrollOffset, direction); - }, 100) - : undefined, - [onScrollEnd], - ); - - useEffect(() => { - return () => { - debouncedOnScrollEnd?.cancel(); - }; - }, [debouncedOnScrollEnd]); - const handleOnScroll = useCallback( ({ scrollDirection, scrollOffset }: ListOnScrollProps) => { onScroll?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up'); - debouncedOnScrollEnd?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up'); + onScrollEnd?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up'); }, - [onScroll, debouncedOnScrollEnd], + [onScroll, onScrollEnd], ); - const debouncedOnItemsRendered = useMemo(() => { - return debounce((items: ListOnItemsRenderedProps) => { + const handleOnItemsRendered = useCallback( + (items: ListOnItemsRenderedProps) => { onRangeChanged?.({ startIndex: items.visibleStartIndex * (tableMeta?.columnCount || 0), stopIndex: items.visibleStopIndex * (tableMeta?.columnCount || 0), }); - }, 50); - }, [onRangeChanged, tableMeta?.columnCount]); + }, + [onRangeChanged, tableMeta?.columnCount], + ); if (!tableMeta) { return null; @@ -169,7 +153,7 @@ const VirtualizedGridList = React.memo( itemCount={itemData.tableMeta?.rowCount || 0} itemData={itemData} itemSize={itemData.tableMeta?.itemHeight || 0} - onItemsRendered={debouncedOnItemsRendered} + onItemsRendered={handleOnItemsRendered} onScroll={handleOnScroll} outerRef={outerRef} ref={ref} diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 9b05b79f8..c25b26114 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -3,7 +3,6 @@ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; -import debounce from 'lodash/debounce'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import React, { @@ -266,22 +265,20 @@ const VirtualizedTableGrid = React.memo( [pinnedLeftColumnCount, pinnedRowCount, CellComponent], ); - const debouncedOnCellsRendered = useMemo(() => { - return debounce( - (items: { - columnStartIndex: number; - columnStopIndex: number; - rowStartIndex: number; - rowStopIndex: number; - }) => { - onRangeChanged?.({ - startIndex: items.rowStartIndex, - stopIndex: items.rowStopIndex, - }); - }, - 45, - ); - }, [onRangeChanged]); + const handleOnCellsRendered = useCallback( + (items: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + }) => { + onRangeChanged?.({ + startIndex: items.rowStartIndex, + stopIndex: items.rowStopIndex, + }); + }, + [onRangeChanged], + ); return (
@@ -379,7 +376,7 @@ const VirtualizedTableGrid = React.memo( columnWidth={(index) => { return columnWidth(index + pinnedLeftColumnCount); }} - onCellsRendered={debouncedOnCellsRendered} + onCellsRendered={handleOnCellsRendered} rowCount={totalRowCount} rowHeight={(index, cellProps) => { return getRowHeight(index + pinnedRowCount, cellProps);