fix: stop lingering reader prefetches from blocking navigation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m58s

This commit is contained in:
2026-03-01 21:14:45 +01:00
parent e6fe5ac27f
commit fead5ff6a0

View File

@@ -21,12 +21,15 @@ export function useImageLoader({
prefetchCount = 5, prefetchCount = 5,
nextBook, nextBook,
}: UseImageLoaderProps) { }: UseImageLoaderProps) {
const PREFETCH_CONCURRENCY = 4;
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({}); const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({}); const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages); const loadedImagesRef = useRef(loadedImages);
const imageBlobUrlsRef = useRef(imageBlobUrls); const imageBlobUrlsRef = useRef(imageBlobUrls);
const isMountedRef = useRef(true);
// Track ongoing fetch requests to prevent duplicates // Track ongoing fetch requests to prevent duplicates
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set()); const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
// Keep refs in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
@@ -37,6 +40,29 @@ export function useImageLoader({
imageBlobUrlsRef.current = imageBlobUrls; imageBlobUrlsRef.current = imageBlobUrls;
}, [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 <T,>(items: T[], worker: (item: T) => Promise<void>, 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( const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, (pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
[bookId] [bookId]
@@ -60,11 +86,14 @@ export function useImageLoader({
// Mark as pending // Mark as pending
pendingFetchesRef.current.add(pageNum); pendingFetchesRef.current.add(pageNum);
const controller = new AbortController();
abortControllersRef.current.set(pageNum, controller);
try { try {
// Use browser cache if available - the server sets Cache-Control headers // Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), { const response = await fetch(getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
return; return;
@@ -76,6 +105,11 @@ export function useImageLoader({
// Create image to get dimensions // Create image to get dimensions
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl);
return;
}
setLoadedImages((prev) => ({ setLoadedImages((prev) => ({
...prev, ...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }, [pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
@@ -87,12 +121,18 @@ export function useImageLoader({
[pageNum]: blobUrl, [pageNum]: blobUrl,
})); }));
}; };
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
};
img.src = blobUrl; img.src = blobUrl;
} catch { } catch {
// Silently fail prefetch // Silently fail prefetch
} finally { } finally {
// Remove from pending set // Remove from pending set
pendingFetchesRef.current.delete(pageNum); pendingFetchesRef.current.delete(pageNum);
abortControllersRef.current.delete(pageNum);
} }
}, },
[getPageUrl] [getPageUrl]
@@ -120,13 +160,12 @@ export function useImageLoader({
// Let all prefetch requests run - the server queue will manage concurrency // Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests // The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) { if (pagesToPrefetch.length > 0) {
// Fire all requests in parallel - server queue handles throttling runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => {
Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => {
// Silently fail - prefetch is non-critical // Silently fail - prefetch is non-critical
}); });
} }
}, },
[prefetchImage, prefetchCount, _pages.length] [prefetchImage, prefetchCount, _pages.length, runWithConcurrency]
); );
// Prefetch pages from next book // Prefetch pages from next book
@@ -153,14 +192,16 @@ export function useImageLoader({
// Let all prefetch requests run - server queue handles concurrency // Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) { if (pagesToPrefetch.length > 0) {
Promise.all( runWithConcurrency(pagesToPrefetch, async ({ pageNum, nextBookPageKey }) => {
pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending // Mark as pending
pendingFetchesRef.current.add(nextBookPageKey); pendingFetchesRef.current.add(nextBookPageKey);
const controller = new AbortController();
abortControllersRef.current.set(nextBookPageKey, controller);
try { 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 cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
return; return;
@@ -172,6 +213,11 @@ export function useImageLoader({
// Create image to get dimensions // Create image to get dimensions
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl);
return;
}
setLoadedImages((prev) => ({ setLoadedImages((prev) => ({
...prev, ...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }, [nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
@@ -183,19 +229,24 @@ export function useImageLoader({
[nextBookPageKey]: blobUrl, [nextBookPageKey]: blobUrl,
})); }));
}; };
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
};
img.src = blobUrl; img.src = blobUrl;
} catch { } catch {
// Silently fail prefetch // Silently fail prefetch
} finally { } finally {
pendingFetchesRef.current.delete(nextBookPageKey); pendingFetchesRef.current.delete(nextBookPageKey);
abortControllersRef.current.delete(nextBookPageKey);
} }
}) }).catch(() => {
).catch(() => {
// Silently fail - prefetch is non-critical // Silently fail - prefetch is non-critical
}); });
} }
}, },
[nextBook, prefetchCount] [nextBook, prefetchCount, runWithConcurrency]
); );
// Force reload handler // Force reload handler