perf(ui): revised range-based fetching strategy

When the user scrolls in the gallery, we are alerted of the new range of
visible images. Then we fetch those specific images.

Previously, each change of range triggered a throttled function to fetch
that range. The throttle timeout was 100ms.

Now, each change of range appends that range to a list of ranges and
triggers the throttled fetch. The timeout is increased to 500ms, but to
compensate, each fetch handles all ranges that had been accumulated
since the last fetch.

The result is far fewer network requests, but each of them gets more
images.
This commit is contained in:
psychedelicious
2025-07-10 12:59:35 +10:00
parent 9cf82de8c5
commit ccc62ba56d

View File

@@ -1,5 +1,5 @@
import { useAppStore } from 'app/store/storeHooks';
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { ListRange } from 'react-virtuoso';
import { imagesApi, useGetImageDTOsByNamesMutation } from 'services/api/endpoints/images';
import { useThrottledCallback } from 'use-debounce';
@@ -13,33 +13,20 @@ interface UseRangeBasedImageFetchingReturn {
onRangeChanged: (range: ListRange) => void;
}
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], range: ListRange): string[] => {
if (range.startIndex === range.endIndex) {
// If the start and end indices are the same, no range to fetch
return [];
}
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], ranges: ListRange[]): string[] => {
const uncachedNamesSet = new Set<string>();
const cachedImageNamesSet = new Set(cachedImageNames);
if (imageNames.length === 0) {
return [];
}
const start = Math.max(0, range.startIndex);
const end = Math.min(imageNames.length - 1, range.endIndex);
if (cachedImageNames.length === 0) {
return imageNames.slice(start, end + 1);
}
const uncachedNames: string[] = [];
for (let i = start; i <= end; i++) {
const imageName = imageNames[i]!;
if (!cachedImageNames.includes(imageName)) {
uncachedNames.push(imageName);
for (const range of ranges) {
for (let i = range.startIndex; i <= range.endIndex; i++) {
const n = imageNames[i]!;
if (n && !cachedImageNamesSet.has(n)) {
uncachedNamesSet.add(n);
}
}
}
return uncachedNames;
return Array.from(uncachedNamesSet);
};
/**
@@ -53,39 +40,33 @@ export const useRangeBasedImageFetching = ({
}: UseRangeBasedImageFetchingArgs): UseRangeBasedImageFetchingReturn => {
const store = useAppStore();
const [getImageDTOsByNames] = useGetImageDTOsByNamesMutation();
const lastRangeRef = useRef<ListRange | null>(null);
const [pendingRanges, setPendingRanges] = useState<ListRange[]>([]);
const fetchImages = useCallback(
(visibleRange: ListRange) => {
(ranges: ListRange[], imageNames: string[]) => {
if (!enabled) {
return;
}
const cachedImageNames = imagesApi.util.selectCachedArgsForQuery(store.getState(), 'getImageDTO');
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, visibleRange);
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, ranges);
if (uncachedNames.length === 0) {
return;
}
getImageDTOsByNames({ image_names: uncachedNames });
lastRangeRef.current = visibleRange;
setPendingRanges([]);
},
[enabled, getImageDTOsByNames, imageNames, store]
[enabled, getImageDTOsByNames, store]
);
const throttledFetchImages = useThrottledCallback(fetchImages, 100);
const throttledFetchImages = useThrottledCallback(fetchImages, 500);
const onRangeChanged = useCallback(
(range: ListRange) => {
throttledFetchImages(range);
},
[throttledFetchImages]
);
const onRangeChanged = useCallback((range: ListRange) => {
setPendingRanges((prev) => [...prev, range]);
}, []);
useEffect(() => {
if (!lastRangeRef.current) {
return;
}
throttledFetchImages(lastRangeRef.current);
}, [imageNames, throttledFetchImages]);
throttledFetchImages(pendingRanges, imageNames);
}, [imageNames, pendingRanges, throttledFetchImages]);
return {
onRangeChanged,