use throttle for item loader, simplify implementation

This commit is contained in:
jeffvli
2025-11-14 03:06:44 -08:00
parent 2da6894ee5
commit 8ac3f2a6f7
3 changed files with 112 additions and 247 deletions
@@ -4,7 +4,8 @@ import {
useSuspenseQuery, useSuspenseQuery,
UseSuspenseQueryOptions, UseSuspenseQueryOptions,
} from '@tanstack/react-query'; } 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 { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
@@ -38,7 +39,6 @@ interface UseItemListInfiniteLoaderProps {
itemType: LibraryItem; itemType: LibraryItem;
listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>; listCountQuery: UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>; listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;
maxPagesToFetch?: number;
query: Record<string, any>; query: Record<string, any>;
serverId: string; serverId: string;
} }
@@ -57,17 +57,11 @@ export const useItemListInfiniteLoader = ({
itemType, itemType,
listCountQuery, listCountQuery,
listQueryFn, listQueryFn,
maxPagesToFetch = 2,
query = {}, query = {},
serverId, serverId,
}: UseItemListInfiniteLoaderProps) => { }: UseItemListInfiniteLoaderProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const scrollStateRef = useRef<ScrollState>({
direction: 'unknown',
lastStartIndex: null,
});
const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery); const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(listCountQuery);
const { setItemCount } = useListContext(); const { setItemCount } = useListContext();
@@ -105,27 +99,8 @@ export const useItemListInfiniteLoader = ({
queryKey: dataQueryKey, queryKey: dataQueryKey,
}); });
const onRangeChanged = useMemo(() => { const fetchPage = useCallback(
return async (range: { startIndex: number; stopIndex: number }) => { async (pageNumber: number) => {
const fetchRange = getFetchRange(
range,
scrollStateRef,
itemsPerPage,
maxPagesToFetch,
fetchThreshold,
);
// Filter out pages that are already loaded
const pagesToFetch = fetchRange.pagesToFetch.filter(
(pageNumber) => !data.pagesLoaded[pageNumber],
);
if (pagesToFetch.length === 0) {
return;
}
// Create fetch promises for all pages
const fetchPromises = pagesToFetch.map(async (pageNumber) => {
const startIndex = pageNumber * itemsPerPage; const startIndex = pageNumber * itemsPerPage;
const queryParams = { const queryParams = {
limit: itemsPerPage, limit: itemsPerPage,
@@ -147,33 +122,21 @@ export const useItemListInfiniteLoader = ({
staleTime: 1000 * 15, staleTime: 1000 * 15,
}); });
return { const endIndex = startIndex + itemsPerPage;
data: result,
endIndex: startIndex + itemsPerPage,
pageNumber,
startIndex,
};
});
// Wait for all pages to be fetched // Update the query data with the fetched page
const pageResults = await Promise.all(fetchPromises);
// Update the query data with all fetched pages
queryClient.setQueryData( queryClient.setQueryData(
dataQueryKey, dataQueryKey,
(oldData: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => { (oldData: { data: unknown[]; pagesLoaded: Record<string, boolean> }) => {
let newData = [...oldData.data]; const newData = [
const newPagesLoaded = { ...oldData.pagesLoaded }; ...oldData.data.slice(0, startIndex),
...result,
// Update data for each fetched page ...oldData.data.slice(endIndex),
pageResults.forEach(({ data: pageData, endIndex, pageNumber, startIndex }) => {
newData = [
...newData.slice(0, startIndex),
...pageData,
...newData.slice(endIndex),
]; ];
newPagesLoaded[pageNumber] = true; const newPagesLoaded = {
}); ...oldData.pagesLoaded,
[pageNumber]: true,
};
return { return {
data: newData, data: newData,
@@ -181,19 +144,63 @@ export const useItemListInfiniteLoader = ({
}; };
}, },
); );
}; },
}, [ [itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],
itemsPerPage, );
query,
queryClient, const onRangeChangedBase = useCallback(
serverId, async (range: { startIndex: number; stopIndex: number }) => {
dataQueryKey, const pageNumber = Math.floor(range.startIndex / itemsPerPage);
listQueryFn,
itemType, const currentData = queryClient.getQueryData<{
data, data: unknown[];
maxPagesToFetch, pagesLoaded: Record<string, boolean>;
fetchThreshold, }>(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( const refresh = useCallback(
async (force?: boolean) => { async (force?: boolean) => {
@@ -309,126 +316,3 @@ export const parseListCountQuery = (query: any) => {
startIndex: 0, startIndex: 0,
}; };
}; };
interface ScrollState {
direction: 'down' | 'unknown' | 'up';
lastStartIndex: null | number;
}
const getFetchRange = (
range: { startIndex: number; stopIndex: number },
scrollState: React.MutableRefObject<ScrollState>,
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,
};
};
@@ -1,6 +1,5 @@
import { useElementSize, useMergedRef } from '@mantine/hooks'; import { useElementSize, useMergedRef } from '@mantine/hooks';
import clsx from 'clsx'; import clsx from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
@@ -114,38 +113,23 @@ const VirtualizedGridList = React.memo(
itemType, 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( const handleOnScroll = useCallback(
({ scrollDirection, scrollOffset }: ListOnScrollProps) => { ({ scrollDirection, scrollOffset }: ListOnScrollProps) => {
onScroll?.(scrollOffset, scrollDirection === 'forward' ? 'down' : 'up'); 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(() => { const handleOnItemsRendered = useCallback(
return debounce((items: ListOnItemsRenderedProps) => { (items: ListOnItemsRenderedProps) => {
onRangeChanged?.({ onRangeChanged?.({
startIndex: items.visibleStartIndex * (tableMeta?.columnCount || 0), startIndex: items.visibleStartIndex * (tableMeta?.columnCount || 0),
stopIndex: items.visibleStopIndex * (tableMeta?.columnCount || 0), stopIndex: items.visibleStopIndex * (tableMeta?.columnCount || 0),
}); });
}, 50); },
}, [onRangeChanged, tableMeta?.columnCount]); [onRangeChanged, tableMeta?.columnCount],
);
if (!tableMeta) { if (!tableMeta) {
return null; return null;
@@ -169,7 +153,7 @@ const VirtualizedGridList = React.memo(
itemCount={itemData.tableMeta?.rowCount || 0} itemCount={itemData.tableMeta?.rowCount || 0}
itemData={itemData} itemData={itemData}
itemSize={itemData.tableMeta?.itemHeight || 0} itemSize={itemData.tableMeta?.itemHeight || 0}
onItemsRendered={debouncedOnItemsRendered} onItemsRendered={handleOnItemsRendered}
onScroll={handleOnScroll} onScroll={handleOnScroll}
outerRef={outerRef} outerRef={outerRef}
ref={ref} ref={ref}
@@ -3,7 +3,6 @@
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { useMergedRef } from '@mantine/hooks'; import { useMergedRef } from '@mantine/hooks';
import clsx from 'clsx'; import clsx from 'clsx';
import debounce from 'lodash/debounce';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { useOverlayScrollbars } from 'overlayscrollbars-react';
import React, { import React, {
@@ -266,8 +265,7 @@ const VirtualizedTableGrid = React.memo(
[pinnedLeftColumnCount, pinnedRowCount, CellComponent], [pinnedLeftColumnCount, pinnedRowCount, CellComponent],
); );
const debouncedOnCellsRendered = useMemo(() => { const handleOnCellsRendered = useCallback(
return debounce(
(items: { (items: {
columnStartIndex: number; columnStartIndex: number;
columnStopIndex: number; columnStopIndex: number;
@@ -279,9 +277,8 @@ const VirtualizedTableGrid = React.memo(
stopIndex: items.rowStopIndex, stopIndex: items.rowStopIndex,
}); });
}, },
45, [onRangeChanged],
); );
}, [onRangeChanged]);
return ( return (
<div className={styles.itemTableContainer}> <div className={styles.itemTableContainer}>
@@ -379,7 +376,7 @@ const VirtualizedTableGrid = React.memo(
columnWidth={(index) => { columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount); return columnWidth(index + pinnedLeftColumnCount);
}} }}
onCellsRendered={debouncedOnCellsRendered} onCellsRendered={handleOnCellsRendered}
rowCount={totalRowCount} rowCount={totalRowCount}
rowHeight={(index, cellProps) => { rowHeight={(index, cellProps) => {
return getRowHeight(index + pinnedRowCount, cellProps); return getRowHeight(index + pinnedRowCount, cellProps);