mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
use throttle for item loader, simplify implementation
This commit is contained in:
@@ -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,75 +99,44 @@ 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(
|
const startIndex = pageNumber * itemsPerPage;
|
||||||
range,
|
const queryParams = {
|
||||||
scrollStateRef,
|
limit: itemsPerPage,
|
||||||
itemsPerPage,
|
startIndex,
|
||||||
maxPagesToFetch,
|
...query,
|
||||||
fetchThreshold,
|
};
|
||||||
);
|
|
||||||
|
|
||||||
// Filter out pages that are already loaded
|
const result = await queryClient.ensureQueryData({
|
||||||
const pagesToFetch = fetchRange.pagesToFetch.filter(
|
gcTime: 1000 * 15,
|
||||||
(pageNumber) => !data.pagesLoaded[pageNumber],
|
queryFn: async ({ signal }) => {
|
||||||
);
|
const result = await listQueryFn({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: queryParams,
|
||||||
|
});
|
||||||
|
|
||||||
if (pagesToFetch.length === 0) {
|
return result.items;
|
||||||
return;
|
},
|
||||||
}
|
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
|
||||||
|
staleTime: 1000 * 15,
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all pages to be fetched
|
const endIndex = startIndex + itemsPerPage;
|
||||||
const pageResults = await Promise.all(fetchPromises);
|
|
||||||
|
|
||||||
// Update the query data with all fetched pages
|
// Update the query data with the fetched page
|
||||||
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 = [
|
const newPagesLoaded = {
|
||||||
...newData.slice(0, startIndex),
|
...oldData.pagesLoaded,
|
||||||
...pageData,
|
[pageNumber]: true,
|
||||||
...newData.slice(endIndex),
|
};
|
||||||
];
|
|
||||||
newPagesLoaded[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,22 +265,20 @@ 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;
|
rowStartIndex: number;
|
||||||
rowStartIndex: number;
|
rowStopIndex: number;
|
||||||
rowStopIndex: number;
|
}) => {
|
||||||
}) => {
|
onRangeChanged?.({
|
||||||
onRangeChanged?.({
|
startIndex: items.rowStartIndex,
|
||||||
startIndex: items.rowStartIndex,
|
stopIndex: items.rowStopIndex,
|
||||||
stopIndex: items.rowStopIndex,
|
});
|
||||||
});
|
},
|
||||||
},
|
[onRangeChanged],
|
||||||
45,
|
);
|
||||||
);
|
|
||||||
}, [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);
|
||||||
|
|||||||
Reference in New Issue
Block a user