fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)

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
This commit is contained in:
Paul Nothaft
2026-04-28 15:55:09 +02:00
parent 5275621fcd
commit fcddfe094b
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
@@ -47,7 +47,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchDistance, setTouchDistance] = useState<number | null>(null); const [touchDistance, setTouchDistance] = useState<number | null>(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 [showFeedback, setShowFeedback] = useState(initialShowFeedback);
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
const [feedbackSettings, setFeedbackSettings] = useState<{ const [feedbackSettings, setFeedbackSettings] = useState<{
@@ -374,10 +376,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
touch2.clientY - touch1.clientY touch2.clientY - touch1.clientY
); );
setTouchDistance(distance); setTouchDistance(distance);
setSwipeStart(null); swipeStartRef.current = null;
} else if (e.touches.length === 1 && zoom <= 1) { } else if (e.touches.length === 1 && zoom <= 1) {
const t = e.touches[0]; 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<PhotoLightboxProps> = ({
const handleTouchEnd = (e: React.TouchEvent) => { const handleTouchEnd = (e: React.TouchEvent) => {
setTouchDistance(null); setTouchDistance(null);
if (swipeStart && e.changedTouches.length > 0) { const start = swipeStartRef.current;
if (start && e.changedTouches.length > 0) {
const t = e.changedTouches[0]; const t = e.changedTouches[0];
const dx = t.clientX - swipeStart.x; const dx = t.clientX - start.x;
const dy = t.clientY - swipeStart.y; const dy = t.clientY - start.y;
const dt = Date.now() - swipeStart.t; const dt = Date.now() - start.t;
// Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms. // 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 (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) {
if (dx > 0) goToPrevious(); if (dx > 0) goToPrevious();
else goToNext(); else goToNext();
} }
} }
setSwipeStart(null); swipeStartRef.current = null;
}; };
// Apply protection class to the lightbox container // Apply protection class to the lightbox container