From e3b7662a0aeec37e0daa07f33a26dc061bda2d89 Mon Sep 17 00:00:00 2001 From: Chester Wood Date: Thu, 9 Apr 2026 14:16:44 -0600 Subject: [PATCH 1/2] 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) { From 2d76e8e38070f87fba5469f006d3d592b55cb299 Mon Sep 17 00:00:00 2001 From: Chester Wood Date: Wed, 13 May 2026 16:15:40 -0600 Subject: [PATCH 2/2] Reset drag state after active data changes - Track data changes that happen while a drag is still active. - Run the skipped animated state reset after the active drag clears. --- src/components/DraggableFlatList.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx index 8fcdf608..9fabe155 100644 --- a/src/components/DraggableFlatList.tsx +++ b/src/components/DraggableFlatList.tsx @@ -112,18 +112,31 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { }); const dataRef = useRef(props.data); + const pendingDataResetRef = useRef(false); const dataHasChanged = dataRef.current.map(keyExtractor).join("") !== props.data.map(keyExtractor).join(""); dataRef.current = props.data; - if (dataHasChanged && !activeKey) { + if (dataHasChanged) { // 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. + if (activeKey) { + pendingDataResetRef.current = true; + } else { + InteractionManager.runAfterInteractions(() => { + reset(); + }); + } + } + + useEffect(() => { + if (activeKey || !pendingDataResetRef.current) return; + pendingDataResetRef.current = false; InteractionManager.runAfterInteractions(() => { reset(); }); - } + }, [activeKey, reset]); useEffect(() => { if (!propsRef.current.enableLayoutAnimationExperimental) return;