fix: use refs in useTabDrag to prevent stale closures on drop

handleDrop and handleDragOver closed over dragIndex/dropIndex state
values. When the last dragover and drop events fire in the same React
render cycle, the drop handler reads stale state (often null), causing
computeDropTarget to return null and skip the reorder.

Mirror dragIndex/dropIndex into refs that are updated synchronously
alongside setState. Event handlers now read from refs, ensuring they
always see the latest values regardless of React's batching schedule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-24 10:13:43 +01:00
parent db3ace23ff
commit 8164576d6c

View File

@@ -100,16 +100,25 @@ function computeInsertIndex(e: React.DragEvent<HTMLDivElement>, index: number):
function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dropIndex, setDropIndex] = useState<number | null>(null)
// Refs mirror state so event handlers always read the latest values,
// avoiding stale closures when dragover and drop fire in the same frame.
const dragIndexRef = useRef<number | null>(null)
const dropIndexRef = useRef<number | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const onReorderRef = useRef(onReorderTabs)
useEffect(() => { onReorderRef.current = onReorderTabs })
const resetDrag = useCallback(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = ''
dragNodeRef.current = null
dragIndexRef.current = null
dropIndexRef.current = null
setDragIndex(null)
setDropIndex(null)
}, [])
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
dragIndexRef.current = index
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
@@ -122,20 +131,32 @@ function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragIndex === null || dragIndex === index) { setDropIndex(null); return }
setDropIndex(computeInsertIndex(e, index))
}, [dragIndex])
const currentDrag = dragIndexRef.current
if (currentDrag === null || currentDrag === index) {
dropIndexRef.current = null
setDropIndex(null)
return
}
const idx = computeInsertIndex(e, index)
dropIndexRef.current = idx
setDropIndex(idx)
}, [])
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
const toIndex = computeDropTarget(dragIndex, dropIndex)
if (toIndex !== null && onReorderTabs) onReorderTabs(dragIndex!, toIndex)
const toIndex = computeDropTarget(dragIndexRef.current, dropIndexRef.current)
if (toIndex !== null && onReorderRef.current) {
onReorderRef.current(dragIndexRef.current!, toIndex)
}
resetDrag()
}, [dragIndex, dropIndex, onReorderTabs, resetDrag])
}, [resetDrag])
const handleBarDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as HTMLElement | null
if (!e.currentTarget.contains(related)) setDropIndex(null)
if (!e.currentTarget.contains(related)) {
dropIndexRef.current = null
setDropIndex(null)
}
}, [])
return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave }