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
@@ -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' } }))
})
})