From fcddfe094b2a01963f7b420afa886e7d5dae4390 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:55:09 +0200 Subject: [PATCH] fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via real-browser verification: with useState the prior commit's handleTouchEnd captures swipeStart from its render closure, so when touchstart and touchend fire inside the same React batch (fast swipe, synthetic events, or a tight render cycle) the end handler reads the stale null and skips navigation. useRef sidesteps the closure entirely and is the right primitive for cross-event scratchpad state anyway. Verified in a 4-photo gallery on mobile-emulation (390x844 touch): - left swipe (-200px) advances 1/4 → 2/4 - right swipe (+200px) returns 2/4 → 1/4 - 20px swipe (under threshold) does not navigate - vertical swipe (dy 300, dx 20) does not navigate --- .../src/components/gallery/PhotoLightbox.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 259107e4..d59fdf7f 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; import type { Photo } from '../../types'; @@ -47,7 +47,9 @@ export const PhotoLightbox: React.FC = ({ const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [touchDistance, setTouchDistance] = useState(null); - const [swipeStart, setSwipeStart] = useState<{ x: number; y: number; t: number } | null>(null); + // Ref (not state) so handleTouchEnd reads the value set by handleTouchStart + // even when both fire in the same render batch. + const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null); const [showFeedback, setShowFeedback] = useState(initialShowFeedback); const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [feedbackSettings, setFeedbackSettings] = useState<{ @@ -374,10 +376,10 @@ export const PhotoLightbox: React.FC = ({ touch2.clientY - touch1.clientY ); setTouchDistance(distance); - setSwipeStart(null); + swipeStartRef.current = null; } else if (e.touches.length === 1 && zoom <= 1) { const t = e.touches[0]; - setSwipeStart({ x: t.clientX, y: t.clientY, t: Date.now() }); + swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; } }; @@ -399,18 +401,19 @@ export const PhotoLightbox: React.FC = ({ const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); - if (swipeStart && e.changedTouches.length > 0) { + const start = swipeStartRef.current; + if (start && e.changedTouches.length > 0) { const t = e.changedTouches[0]; - const dx = t.clientX - swipeStart.x; - const dy = t.clientY - swipeStart.y; - const dt = Date.now() - swipeStart.t; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + const dt = Date.now() - start.t; // Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms. if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) { if (dx > 0) goToPrevious(); else goToNext(); } } - setSwipeStart(null); + swipeStartRef.current = null; }; // Apply protection class to the lightbox container