fix: stop lingering reader prefetches from blocking navigation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m58s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m58s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user