From 98559bf729ad558bc70035725c8400b5761bbf04 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 22:30:27 +0700 Subject: [PATCH] fix(app): release iOS sidebar rows after an interrupted drag (#7159) An iOS pan waiting for manual activation can deliver finger-up without finalizing, leaving the row active and the outer scroller locked. Fail a pending pan when its touch stream ends so the existing finalization path cancels the drag. Track activation immediately so an active drag keeps its normal completion path. Based on the touch-release diagnosis in #5586. The native regression fails before the fix and passes on the Namespace iOS simulator. Co-authored-by: Kassam Khoja --- .../native-sidebar-drag-release.ios.ad | 13 ++++++ ...eact-native-draggable-flatlist+4.0.3.patch | 46 +++++++++++++------ 2 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad new file mode 100644 index 0000000000..c94e24914d --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -0,0 +1,13 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible, +# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms). +open "${APP_ID}" +press "label=\"Open menu\"" +wait "id=\"${WORKSPACE_ROW}\"" 10000 +longpress "id=\"${WORKSPACE_ROW}\"" 250 +press "id=\"${WORKSPACE_ROW}\"" +# A released drag must not swallow the next tap: navigation closes the sidebar. +wait "label=\"Open menu\"" 5000 +close diff --git a/patches/react-native-draggable-flatlist+4.0.3.patch b/patches/react-native-draggable-flatlist+4.0.3.patch index 79838c55e2..ddbdb92224 100644 --- a/patches/react-native-draggable-flatlist+4.0.3.patch +++ b/patches/react-native-draggable-flatlist+4.0.3.patch @@ -58,10 +58,14 @@ index 2abb4a2..8ca967f 100644 onAnimValInit?: (animVals: ReturnType) => void; itemEnteringAnimation?: AnimateProps["entering"]; diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx -index 7c88afc..6d5719c 100644 +index 7c88afc..a74140f 100644 --- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx -@@ -19,6 +19,7 @@ import { +@@ -16,9 +16,11 @@ import { + FlatList, + Gesture, + GestureDetector, ++ State, } from "react-native-gesture-handler"; import Animated, { runOnJS, @@ -69,7 +73,7 @@ index 7c88afc..6d5719c 100644 useAnimatedReaction, useAnimatedScrollHandler, useSharedValue, -@@ -92,6 +93,17 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -92,6 +94,17 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { activeCellOffset.value = -1; setActiveKey(null); }); @@ -87,7 +91,7 @@ index 7c88afc..6d5719c 100644 const { dragHitSlop = DEFAULT_PROPS.dragHitSlop, -@@ -144,19 +156,88 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -144,19 +157,88 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { }); }, [props.data, keyExtractor, keyToIndexRef]); @@ -183,7 +187,7 @@ index 7c88afc..6d5719c 100644 setActiveKey(activeKey); onDragBegin?.(index); } -@@ -218,8 +299,25 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -218,8 +300,25 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { props.onRelease?.(index); }); @@ -210,7 +214,7 @@ index 7c88afc..6d5719c 100644 const { onDragEnd, data } = props; const newData = [...data]; -@@ -238,26 +336,6 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -238,26 +337,6 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { props.onPlaceholderIndexChange?.(index); }); @@ -237,7 +241,7 @@ index 7c88afc..6d5719c 100644 useAnimatedReaction( () => { return spacerIndexAnim.value; -@@ -271,11 +349,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -271,11 +350,24 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { ); const gestureDisabled = useSharedValue(false); @@ -257,10 +261,13 @@ index 7c88afc..6d5719c 100644 + gestureSessionActive.value = true; + terminalHandled.value = false; + runOnJS(onGestureBegin)(sessionId); ++ panGestureState.value = evt.state; ++ }) ++ .onStart((evt) => { panGestureState.value = evt.state; }) .onUpdate((evt) => { -@@ -286,8 +374,15 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -286,8 +378,15 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { : evt.translationY; touchTranslate.value = translation; }) @@ -277,7 +284,7 @@ index 7c88afc..6d5719c 100644 // Set touch val to current translate val isTouchActiveNative.value = false; const translation = horizontalAnim.value -@@ -299,6 +394,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -299,6 +398,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { // Only call onDragEnd if actually dragging a cell if (activeIndexAnim.value === -1 || disabled.value) return; @@ -285,7 +292,7 @@ index 7c88afc..6d5719c 100644 disabled.value = true; runOnJS(onRelease)(activeIndexAnim.value); const springTo = placeholderOffset.value - activeCellOffset.value; -@@ -309,14 +405,64 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -309,22 +409,84 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { runOnJS(onDragEnd)({ from: activeIndexAnim.value, to: spacerIndexAnim.value, @@ -316,6 +323,7 @@ index 7c88afc..6d5719c 100644 + } runOnJS(onContainerTouchStart)(); }) +- .onTouchesUp(() => { + .onTouchesMove((event, stateManager) => { + if (!coordinatesDragWithExternalGesture) return; + @@ -348,10 +356,20 @@ index 7c88afc..6d5719c 100644 + stateManager.activate(); + } + }) - .onTouchesUp(() => { ++ .onTouchesUp((event, stateManager) => { // Turning this into a worklet causes timing issues. We want it to run // just after the finger lifts. -@@ -324,7 +470,9 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + runOnJS(onContainerTouchEnd)(); ++ // A manually activated iOS pan can remain BEGAN after the last finger ++ // lifts. Fail it explicitly so onFinalize cancels the armed row. ++ if (event.numberOfTouches === 0 && panGestureState.value === State.BEGAN) { ++ stateManager.fail(); ++ } ++ }) ++ .onTouchesCancelled((_event, stateManager) => { ++ if (panGestureState.value === State.BEGAN) { ++ stateManager.fail(); ++ } }); if (dragHitSlop) panGesture.hitSlop(dragHitSlop); @@ -362,7 +380,7 @@ index 7c88afc..6d5719c 100644 const activeOffset = [-activationDistanceProp, activationDistanceProp]; if (props.horizontal) { panGesture.activeOffsetX(activeOffset); -@@ -332,6 +480,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -332,6 +494,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { panGesture.activeOffsetY(activeOffset); } } @@ -384,7 +402,7 @@ index 7c88afc..6d5719c 100644 const onScroll = useStableCallback((scrollOffset: number) => { props.onScrollOffsetChange?.(scrollOffset); -@@ -373,8 +536,9 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -373,8 +550,9 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { horizontal={!!props.horizontal} layoutAnimationDisabled={layoutAnimationDisabled} >