feat: implement request deduplication and concurrency management in image loading for improved performance

This commit is contained in:
Julien Froidefond
2025-10-31 13:07:37 +01:00
parent e0b90a7893
commit 349448ef69
4 changed files with 123 additions and 11 deletions

View File

@@ -67,6 +67,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
// Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits
useEffect(() => {
// Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount);

View File

@@ -20,6 +20,8 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages);
const imageBlobUrlsRef = useRef(imageBlobUrls);
// Track ongoing fetch requests to prevent duplicates
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
// Keep refs in sync with state
useEffect(() => {
@@ -42,8 +44,19 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
return;
}
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
}
// Mark as pending
pendingFetchesRef.current.add(pageNum);
try {
const response = await fetch(getPageUrl(pageNum));
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
@@ -68,10 +81,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
}
}, [getPageUrl]);
// Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
@@ -80,17 +98,22 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL
if (!hasDimensions || !hasBlobUrl) {
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum);
}
}
}
// Prefetch all pages in parallel
// Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum)));
// Fire all requests in parallel - server queue handles throttling
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => {
// Silently fail - prefetch is non-critical
});
}
}, [prefetchImage, prefetchCount, _pages.length]);
@@ -108,17 +131,23 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
if (!hasDimensions || !hasBlobUrl) {
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push({ pageNum, nextBookPageKey });
}
}
// Prefetch all pages in parallel
// Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`);
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
@@ -143,8 +172,12 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
}
}));
})).catch(() => {
// Silently fail - prefetch is non-critical
});
}
}, [nextBook, prefetchCount]);