From e3b7662a0aeec37e0daa07f33a26dc061bda2d89 Mon Sep 17 00:00:00 2001 From: Chester Wood Date: Thu, 9 Apr 2026 14:16:44 -0600 Subject: [PATCH] Fix race condition causing items to disappear after drag direction reversal --- src/components/CellRendererComponent.tsx | 9 +++++++++ src/components/DraggableFlatList.tsx | 7 ++++--- src/hooks/useCellTranslate.tsx | 18 +++++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/CellRendererComponent.tsx b/src/components/CellRendererComponent.tsx index e4e861a6..7ed72651 100644 --- a/src/components/CellRendererComponent.tsx +++ b/src/components/CellRendererComponent.tsx @@ -55,6 +55,15 @@ function CellRendererComponent(props: Props) { const isActive = activeKey === key; + // Reset held translate when drag ends, in case onCellLayout doesn't fire + // (e.g. when FlatList doesn't detect a geometry change for the cell). + // By this point the spring animation has already completed. + useEffect(() => { + if (!activeKey) { + heldTanslate.value = 0; + } + }, [activeKey]); + const animStyle = useAnimatedStyle(() => { // When activeKey becomes null at the end of a drag and the list reorders, // the animated style may apply before the next paint, causing a flicker. diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx index 7c88afc5..8fcdf608 100644 --- a/src/components/DraggableFlatList.tsx +++ b/src/components/DraggableFlatList.tsx @@ -116,9 +116,10 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { dataRef.current.map(keyExtractor).join("") !== props.data.map(keyExtractor).join(""); dataRef.current = props.data; - if (dataHasChanged) { - // When data changes make sure `activeKey` is nulled out in the same render pass - activeKey = null; + if (dataHasChanged && !activeKey) { + // When data changes (and no drag is active) reset animated values. + // Guard against activeKey to prevent reset() from racing with the + // spring animation callback during an active drag. InteractionManager.runAfterInteractions(() => { reset(); }); diff --git a/src/hooks/useCellTranslate.tsx b/src/hooks/useCellTranslate.tsx index efea2403..7af6416b 100644 --- a/src/hooks/useCellTranslate.tsx +++ b/src/hooks/useCellTranslate.tsx @@ -13,6 +13,7 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { const { activeIndexAnim, activeCellSize, + activeCellOffset, hoverOffset, spacerIndexAnim, placeholderOffset, @@ -75,7 +76,22 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { } if (result !== -1 && result !== spacerIndexAnim.value) { - spacerIndexAnim.value = result; + // Direction-aware guard: during a drag reversal, both a before-active + // and after-active cell can match overlap conditions in the same frame. + // Only allow the write if the result is on the correct side relative + // to the hover direction, preventing the "wrong side" cell from winning. + const isHoverBelowOrigin = hoverOffset.value >= activeCellOffset.value; + const resultIsAfterActive = result > activeIndexAnim.value; + const resultIsBeforeActive = result < activeIndexAnim.value; + const resultIsAtActive = result === activeIndexAnim.value; + + const directionMatch = + (isHoverBelowOrigin && (resultIsAfterActive || resultIsAtActive)) || + (!isHoverBelowOrigin && (resultIsBeforeActive || resultIsAtActive)); + + if (spacerIndexAnim.value < 0 || directionMatch) { + spacerIndexAnim.value = result; + } } if (spacerIndexAnim.value === cellIndex) {