From 7bb03be51a745a5b1cde23b6bdbf7e6d8911b6a6 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 22:07:46 +0200 Subject: [PATCH] fix: stable preview nav handlers via ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/preview/PreviewView.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index 90e7ed2..86c0347 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -45,22 +45,38 @@ export function PreviewView() { const safeIndex = currentIndex < 0 ? 0 : currentIndex 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(() => { - if (photos.length === 0) return - const next = Math.max(0, safeIndex - 1) - setActivePhoto(photos[next].id) - }, [photos, safeIndex, setActivePhoto]) + const { photos: ps, activePhotoId: aid } = navRef.current + if (ps.length === 0) return + const idx = aid ? ps.findIndex((p) => p.id === aid) : 0 + const safe = idx < 0 ? 0 : idx + const next = Math.max(0, safe - 1) + setActivePhoto(ps[next].id) + }, [setActivePhoto]) const goNext = useCallback(() => { - if (photos.length === 0) return - const next = Math.min(photos.length - 1, safeIndex + 1) - setActivePhoto(photos[next].id) - }, [photos, safeIndex, setActivePhoto]) + const { photos: ps, activePhotoId: aid } = navRef.current + if (ps.length === 0) return + const idx = aid ? ps.findIndex((p) => p.id === aid) : 0 + 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. + // 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('left', goPrev, { preventDefault: true }, [goPrev]) - useHotkeys('right', goNext, { preventDefault: true }, [goNext]) + useHotkeys('left', goPrev, { preventDefault: true }) + useHotkeys('right', goNext, { preventDefault: true }) useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true }) // Preload the immediate neighbors so arrow nav feels instant. Skip videos