fix: stable preview nav handlers via ref

react-hotkeys-hook can fire a stale closure when the callback dependency
array changes between renders, causing arrow nav to read an old photos
array (e.g. the empty initial render before visiblePhotoIds was applied)
and land on the wrong photo or no-op entirely.

Move the latest photos / activePhotoId into a navRef updated on every
render. The goPrev / goNext callbacks become stable (their useCallback
deps shrink to just setActivePhoto) and read the freshest values from
the ref at fire time. useHotkeys no longer has to re-bind on every
render — the handlers can capture the ref once.

The visible-order array still drives navigation; this just removes the
re-bind race that was making it look like nav was ignoring it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 22:07:46 +02:00
parent 123c60ed2c
commit 7bb03be51a

View File

@@ -45,22 +45,38 @@ export function PreviewView() {
const safeIndex = currentIndex < 0 ? 0 : currentIndex const safeIndex = currentIndex < 0 ? 0 : currentIndex
const currentPhoto: Photo | undefined = photos[safeIndex] const currentPhoto: Photo | undefined = photos[safeIndex]
// Keep the latest photos array + active id in a ref so the keyboard
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
// hook can fire a closure that captured an older photos array (e.g.
// the empty initial render before visiblePhotoIds was applied) and
// arrow nav lands on the wrong photo or no-ops.
const navRef = useRef({ photos, activePhotoId })
navRef.current = { photos, activePhotoId }
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (photos.length === 0) return const { photos: ps, activePhotoId: aid } = navRef.current
const next = Math.max(0, safeIndex - 1) if (ps.length === 0) return
setActivePhoto(photos[next].id) const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
}, [photos, safeIndex, setActivePhoto]) const safe = idx < 0 ? 0 : idx
const next = Math.max(0, safe - 1)
setActivePhoto(ps[next].id)
}, [setActivePhoto])
const goNext = useCallback(() => { const goNext = useCallback(() => {
if (photos.length === 0) return const { photos: ps, activePhotoId: aid } = navRef.current
const next = Math.min(photos.length - 1, safeIndex + 1) if (ps.length === 0) return
setActivePhoto(photos[next].id) const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
}, [photos, safeIndex, setActivePhoto]) const safe = idx < 0 ? 0 : idx
const next = Math.min(ps.length - 1, safe + 1)
setActivePhoto(ps[next].id)
}, [setActivePhoto])
// Preview-scoped hotkeys: only mounted while PreviewView is rendered. // Preview-scoped hotkeys: only mounted while PreviewView is rendered.
// The handlers themselves are stable (refs internally) so the deps
// array stays empty — useHotkeys won't have to re-bind on every render.
useHotkeys('escape', closePreview, { preventDefault: true }) useHotkeys('escape', closePreview, { preventDefault: true })
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev]) useHotkeys('left', goPrev, { preventDefault: true })
useHotkeys('right', goNext, { preventDefault: true }, [goNext]) useHotkeys('right', goNext, { preventDefault: true })
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true }) useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
// Preload the immediate neighbors so arrow nav feels instant. Skip videos // Preload the immediate neighbors so arrow nav feels instant. Skip videos