Prefetch artwork prev/next navigation off the critical path.

Move neighbor lookup into a dedicated service and let the viewer load adjacent artworks after first paint instead of blocking the detail request.
This commit is contained in:
2026-08-29 12:25:49 +02:00
parent bf902f509a
commit 437d943827
7 changed files with 430 additions and 45 deletions
@@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavContext } from '../../lib/useNavContext';
const preloadCache = new Set();
const prefetchCache = new Set();
function scheduleIdleTask(callback, delay = 1200) {
if (typeof window === 'undefined') {
@@ -32,8 +33,8 @@ function preloadImage(src) {
img.src = src;
}
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
const { getNeighbors } = useNavContext(artworkId);
export default function ArtworkNavigator({ artworkId, navigation = null, onNavigate, onOpenViewer, onReady }) {
const { getNeighbors } = useNavContext(artworkId, navigation);
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
@@ -67,17 +68,42 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
if (ids.length === 0) return undefined;
const previews = new Map([
[neighbors.prevId, neighbors.prevPreview],
[neighbors.nextId, neighbors.nextPreview],
]);
let cancelled = false;
const cancelIdleTask = scheduleIdleTask(() => {
ids.forEach((id) => {
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
.then((r) => r.ok ? r.json() : null)
if (prefetchCache.has(id)) return;
prefetchCache.add(id);
const preview = previews.get(id);
if (preview) {
if (!cancelled) {
const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
}
return;
}
fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: 'application/json' } })
.then((r) => {
if (!r.ok) {
prefetchCache.delete(id);
return null;
}
return r.json();
})
.then((data) => {
if (cancelled || !data) return;
if (!data) {
prefetchCache.delete(id);
return;
}
if (cancelled) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
})
.catch(() => {});
.catch(() => prefetchCache.delete(id));
});
});
@@ -85,7 +111,7 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
cancelled = true;
cancelIdleTask();
};
}, [neighbors.prevId, neighbors.nextId]);
}, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
// Stable navigate — reads state via refs, never recreated
const navigate = useCallback(async (targetId, targetUrl) => {