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:
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import ArtworkNavigator from './ArtworkNavigator'
|
||||
|
||||
describe('ArtworkNavigator preview prefetch', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
delete window.requestIdleCallback
|
||||
delete window.cancelIdleCallback
|
||||
})
|
||||
|
||||
it('seeds neighbor prefetch from navigation and keeps full navigation separate', async () => {
|
||||
let idleCallback
|
||||
let ready
|
||||
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
|
||||
window.cancelIdleCallback = vi.fn()
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('/api/artworks/navigation/777')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
prev_id: 776,
|
||||
next_id: 778,
|
||||
prev_url: '/art/776/previous',
|
||||
next_url: '/art/778/next',
|
||||
prev_slug: 'previous',
|
||||
next_slug: 'next',
|
||||
prev_preview: { id: 776, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-776' }, lg: { url: '/lg-776' } } },
|
||||
next_preview: { id: 778, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-778' }, lg: { url: '/lg-778' } } },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({ ok: true, json: async () => ({ id: 778, title: 'Next', slug: 'next' }) })
|
||||
})
|
||||
|
||||
render(<ArtworkNavigator artworkId={777} onNavigate={vi.fn()} onReady={(next) => { if (next.hasNext) ready = next }} />)
|
||||
await waitFor(() => expect(ready?.hasNext).toBe(true))
|
||||
await act(async () => idleCallback())
|
||||
|
||||
expect(global.fetch.mock.calls.filter(([url]) => String(url).includes('?prefetch=1'))).toHaveLength(0)
|
||||
await act(async () => ready.navigateNext())
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/artworks/778/page', { headers: { Accept: 'application/json' } })
|
||||
})
|
||||
|
||||
it('uses embedded initial navigation without a standalone or prefetch request', async () => {
|
||||
let idleCallback
|
||||
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
|
||||
window.cancelIdleCallback = vi.fn()
|
||||
global.fetch = vi.fn()
|
||||
const navigation = {
|
||||
prev_id: 880,
|
||||
next_id: 882,
|
||||
prev_url: '/art/880/previous',
|
||||
next_url: '/art/882/next',
|
||||
prev_slug: 'previous',
|
||||
next_slug: 'next',
|
||||
prev_preview: { id: 880, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-880' }, lg: { url: '/lg-880' } } },
|
||||
next_preview: { id: 882, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-882' }, lg: { url: '/lg-882' } } },
|
||||
}
|
||||
|
||||
render(<ArtworkNavigator artworkId={881} navigation={navigation} onNavigate={vi.fn()} onReady={vi.fn()} />)
|
||||
await waitFor(() => expect(window.requestIdleCallback).toHaveBeenCalledTimes(1))
|
||||
await act(async () => idleCallback())
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to standalone navigation for malformed embedded data', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ prev_id: 889, next_id: 891, prev_url: '/art/889/p', next_url: '/art/891/n', prev_slug: 'p', next_slug: 'n' }),
|
||||
})
|
||||
|
||||
render(<ArtworkNavigator artworkId={890} navigation={{ prev_id: 889 }} onNavigate={vi.fn()} onReady={vi.fn()} />)
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/artworks/navigation/890', { headers: { Accept: 'application/json' } }))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user