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,
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<number, Error, number, readonly unknown[]>;
listQueryFn: (args: { apiClientProps: any; query: any }) => Promise<{ items: unknown[] }>;
maxPagesToFetch?: number;
query: Record<string, any>;
serverId: string;
}
@@ -57,17 +57,11 @@ export const useItemListInfiniteLoader = ({
itemType,
listCountQuery,
listQueryFn,
maxPagesToFetch = 2,
query = {},
serverId,
}: UseItemListInfiniteLoaderProps) => {
const queryClient = useQueryClient();
const scrollStateRef = useRef<ScrollState>({
direction: 'unknown',
lastStartIndex: null,
});
const { data: totalItemCount } = useSuspenseQuery<number, any, number, any>(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<string, boolean> }) => {
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<string, boolean>;
}>(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<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 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}
@@ -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 (
<div className={styles.itemTableContainer}>
@@ -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);