From fead5ff6a0a8e9ee864122c99d2cbed81426d30f Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 1 Mar 2026 21:14:45 +0100 Subject: [PATCH] fix: stop lingering reader prefetches from blocking navigation --- src/components/reader/hooks/useImageLoader.ts | 123 +++++++++++++----- 1 file changed, 87 insertions(+), 36 deletions(-) diff --git a/src/components/reader/hooks/useImageLoader.ts b/src/components/reader/hooks/useImageLoader.ts index e5cd2f1..5f13866 100644 --- a/src/components/reader/hooks/useImageLoader.ts +++ b/src/components/reader/hooks/useImageLoader.ts @@ -21,12 +21,15 @@ export function useImageLoader({ prefetchCount = 5, nextBook, }: UseImageLoaderProps) { + const PREFETCH_CONCURRENCY = 4; const [loadedImages, setLoadedImages] = useState>({}); const [imageBlobUrls, setImageBlobUrls] = useState>({}); const loadedImagesRef = useRef(loadedImages); const imageBlobUrlsRef = useRef(imageBlobUrls); + const isMountedRef = useRef(true); // Track ongoing fetch requests to prevent duplicates const pendingFetchesRef = useRef>(new Set()); + const abortControllersRef = useRef>(new Map()); // Keep refs in sync with state useEffect(() => { @@ -37,6 +40,29 @@ export function useImageLoader({ imageBlobUrlsRef.current = imageBlobUrls; }, [imageBlobUrls]); + useEffect(() => { + isMountedRef.current = true; + const abortControllers = abortControllersRef.current; + const pendingFetches = pendingFetchesRef.current; + + return () => { + isMountedRef.current = false; + abortControllers.forEach((controller) => controller.abort()); + abortControllers.clear(); + pendingFetches.clear(); + }; + }, []); + + const runWithConcurrency = useCallback( + async (items: T[], worker: (item: T) => Promise, concurrency = PREFETCH_CONCURRENCY) => { + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + await Promise.all(batch.map((item) => worker(item))); + } + }, + [PREFETCH_CONCURRENCY] + ); + const getPageUrl = useCallback( (pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId] @@ -60,11 +86,14 @@ export function useImageLoader({ // Mark as pending pendingFetchesRef.current.add(pageNum); + const controller = new AbortController(); + abortControllersRef.current.set(pageNum, controller); try { // 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 + signal: controller.signal, }); if (!response.ok) { return; @@ -76,6 +105,11 @@ export function useImageLoader({ // Create image to get dimensions const img = new Image(); img.onload = () => { + if (!isMountedRef.current || controller.signal.aborted) { + URL.revokeObjectURL(blobUrl); + return; + } + setLoadedImages((prev) => ({ ...prev, [pageNum]: { width: img.naturalWidth, height: img.naturalHeight }, @@ -87,12 +121,18 @@ export function useImageLoader({ [pageNum]: blobUrl, })); }; + + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + }; + img.src = blobUrl; } catch { // Silently fail prefetch } finally { // Remove from pending set pendingFetchesRef.current.delete(pageNum); + abortControllersRef.current.delete(pageNum); } }, [getPageUrl] @@ -120,13 +160,12 @@ export function useImageLoader({ // Let all prefetch requests run - the server queue will manage concurrency // The browser cache and our deduplication prevent redundant requests if (pagesToPrefetch.length > 0) { - // Fire all requests in parallel - server queue handles throttling - Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => { + runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => { // Silently fail - prefetch is non-critical }); } }, - [prefetchImage, prefetchCount, _pages.length] + [prefetchImage, prefetchCount, _pages.length, runWithConcurrency] ); // Prefetch pages from next book @@ -153,49 +192,61 @@ export function useImageLoader({ // Let all prefetch requests run - server queue handles concurrency if (pagesToPrefetch.length > 0) { - Promise.all( - pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => { - // Mark as pending - pendingFetchesRef.current.add(nextBookPageKey); + runWithConcurrency(pagesToPrefetch, async ({ pageNum, nextBookPageKey }) => { + // Mark as pending + pendingFetchesRef.current.add(nextBookPageKey); + const controller = new AbortController(); + abortControllersRef.current.set(nextBookPageKey, controller); - try { - const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { - cache: "default", // Respect Cache-Control headers from server - }); - if (!response.ok) { + try { + const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { + cache: "default", // Respect Cache-Control headers from server + signal: controller.signal, + }); + if (!response.ok) { + return; + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Create image to get dimensions + const img = new Image(); + img.onload = () => { + if (!isMountedRef.current || controller.signal.aborted) { + URL.revokeObjectURL(blobUrl); return; } - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); + setLoadedImages((prev) => ({ + ...prev, + [nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }, + })); - // Create image to get dimensions - const img = new Image(); - img.onload = () => { - setLoadedImages((prev) => ({ - ...prev, - [nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }, - })); + // Store the blob URL for immediate use + setImageBlobUrls((prev) => ({ + ...prev, + [nextBookPageKey]: blobUrl, + })); + }; - // Store the blob URL for immediate use - setImageBlobUrls((prev) => ({ - ...prev, - [nextBookPageKey]: blobUrl, - })); - }; - img.src = blobUrl; - } catch { - // Silently fail prefetch - } finally { - pendingFetchesRef.current.delete(nextBookPageKey); - } - }) - ).catch(() => { + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + }; + + img.src = blobUrl; + } catch { + // Silently fail prefetch + } finally { + pendingFetchesRef.current.delete(nextBookPageKey); + abortControllersRef.current.delete(nextBookPageKey); + } + }).catch(() => { // Silently fail - prefetch is non-critical }); } }, - [nextBook, prefetchCount] + [nextBook, prefetchCount, runWithConcurrency] ); // Force reload handler