From 47fd3d8b5029bfa436cf71f3b38a8f27325e02b4 Mon Sep 17 00:00:00 2001 From: Nathan Stitt Date: Thu, 6 Aug 2026 10:24:07 -0500 Subject: [PATCH] fix: cross-container drops reverting on gesture-handler 3.x RNGH PR #3887 moved the end flag from onFinalize's legacy `success` second parameter into the event itself, inverted as `canceled`. The v3 gesture path still read the removed parameter, so on 3.x every normally-ended drag finalized as cancelled --- src/SortableBoardContainer.tsx | 11 +++++++++++ src/compat/finalizeCanceled.ts | 16 ++++++++++++++++ src/compat/index.ts | 1 + src/compat/types.ts | 8 +++++++- src/hooks/useDragGesture.ts | 6 +++--- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 src/compat/finalizeCanceled.ts diff --git a/src/SortableBoardContainer.tsx b/src/SortableBoardContainer.tsx index 1ef725e..df79888 100644 --- a/src/SortableBoardContainer.tsx +++ b/src/SortableBoardContainer.tsx @@ -365,6 +365,17 @@ export const SortableBoardContainer = ({ } } + // A cancelled drag-end arriving after the success path above has already + // run (source info cleared, transfer still pending for finalizeTransfer) + // is stale — reinjecting would revert the completed transfer. Return the + // phantom snap target again so the in-flight snap stays aimed at the + // target column instead of resolving to the default origin target. + if (cancelled && transfer?.targetId && !sourceInfoRef.current) { + draxViewProps?.onMonitorDragEnd?.(eventData); + const staleTarget = columns.get(transfer.targetId); + return staleTarget ? staleTarget.getPhantomSnapTarget() : undefined; + } + if (transfer) { // Cancelled or no target — clear phantom and reinject if (transfer.targetId) { diff --git a/src/compat/finalizeCanceled.ts b/src/compat/finalizeCanceled.ts new file mode 100644 index 0000000..91694ef --- /dev/null +++ b/src/compat/finalizeCanceled.ts @@ -0,0 +1,16 @@ +import type { DraxPanEvent } from './types'; + +/** + * Whether a finalized pan gesture was cancelled, across gesture-handler + * versions. Released v3 reports it as `event.canceled` (PR #3887); v2 and + * v3 betas predating that change pass a legacy `success` second parameter + * instead. Reading only the parameter makes every normal end look cancelled + * on released v3. + */ +export function isFinalizeCanceled( + event: DraxPanEvent, + didSucceed?: boolean +): boolean { + 'worklet'; + return event.canceled ?? didSucceed === false; +} diff --git a/src/compat/index.ts b/src/compat/index.ts index ce38fd0..7e47ca2 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -1,4 +1,5 @@ export { useDraxPanGesture } from './useDraxPanGesture'; +export { isFinalizeCanceled } from './finalizeCanceled'; export type { DraxPanEvent, DraxPanGesture, diff --git a/src/compat/types.ts b/src/compat/types.ts index 521d7ae..c7f2605 100644 --- a/src/compat/types.ts +++ b/src/compat/types.ts @@ -13,6 +13,10 @@ export interface DraxPanEvent { y: number; absoluteX: number; absoluteY: number; + /** Released v3 (gesture-handler PR #3887) moved the end flag into the + * event, inverted, replacing the legacy `success` second parameter. + * Absent on v2 and on v3 betas before the change. */ + canceled?: boolean; } /** Config for the version-agnostic pan gesture hook. */ @@ -31,5 +35,7 @@ export interface DraxPanGestureConfig { onActivate: (event: DraxPanEvent) => void; onUpdate: (event: DraxPanEvent) => void; onDeactivate: (event: DraxPanEvent) => void; - onFinalize: (event: DraxPanEvent, didSucceed: boolean) => void; + /** `didSucceed` is only passed by v2 and by v3 betas predating + * gesture-handler PR #3887; released v3 sends `event.canceled` instead. */ + onFinalize: (event: DraxPanEvent, didSucceed?: boolean) => void; } diff --git a/src/hooks/useDragGesture.ts b/src/hooks/useDragGesture.ts index 4e36d6c..22dbe79 100644 --- a/src/hooks/useDragGesture.ts +++ b/src/hooks/useDragGesture.ts @@ -2,7 +2,7 @@ import { Platform } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { runOnJS } from 'react-native-worklets'; -import { useDraxPanGesture } from '../compat'; +import { isFinalizeCanceled, useDraxPanGesture } from '../compat'; import { computeAbsolutePositionWorklet, hitTestWorklet } from '../math'; import type { Position } from '../types'; import { useDraxContext } from './useDraxContext'; @@ -239,13 +239,13 @@ export const useDragGesture = ( // Bounce to JS for end callbacks + snap animation runOnJS(handleDragEnd)(currentDraggedId, currentReceiverId, false, finalHitResult.monitorIds); }, - onFinalize: (_event, didSucceed) => { + onFinalize: (event, didSucceed) => { 'worklet'; // If gesture was cancelled (not ended normally). // Check draggedIdSV (set in onActivate) instead of dragPhaseSV // because phase is now set later in handleDragStart via runOnUI. - if (!didSucceed && draggedIdSV.value !== '') { + if (isFinalizeCanceled(event, didSucceed) && draggedIdSV.value !== '') { const currentDraggedId = draggedIdSV.value; const currentReceiverId = receiverIdSV.value;