From 3a7cb8f34db7e17d842ad2e8ea3eea73a0bb6723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 27 Jul 2026 08:52:14 +0200 Subject: [PATCH 1/5] Clean up drag state when the item gesture finalizes Drag cleanup ran only from the onTouchesUp and onTouchesCancelled touch callbacks. Those are not guaranteed to arrive when a drag ends for a reason other than the user lifting a finger, such as another gesture winning or the handler being cancelled. When they are skipped, handleDragEnd never runs, so the item keeps a non-zero activation progress and stays the active item, and the activation gate then refuses to start a drag on it ever again. Wire the cleanup to onFinalize, which gesture-handler dispatches from handler state transitions for END, FAILED and CANCELLED alike. The touch callbacks keep running the same cleanup so a touch released before the activation delay still cancels its pending activation timeout. --- .../integrations/gesture-handler/adapters/v2.test.ts | 9 +++++++++ .../src/integrations/gesture-handler/adapters/v2.ts | 3 ++- .../integrations/gesture-handler/adapters/v3.test.ts | 10 ++++++++++ .../src/integrations/gesture-handler/adapters/v3.ts | 4 ++++ .../src/integrations/gesture-handler/types.ts | 5 +++++ .../src/providers/shared/hooks/useItemPanGesture.ts | 9 +++++++++ 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts index 2143f656..5e5ee81e 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts @@ -22,6 +22,7 @@ jest.mock('react-native-gesture-handler', () => { gesture[method] = jest.fn(() => gesture); } for (const method of [ + 'onFinalize', 'onTouchesCancelled', 'onTouchesDown', 'onTouchesMove', @@ -68,6 +69,7 @@ const mocked = Gesture as unknown as { }; const dragCallbacks: ManualGestureCallbacks = { + onFinalize: jest.fn(), onTouchesCancelled: jest.fn(), onTouchesDown: jest.fn(), onTouchesMove: jest.fn(), @@ -196,3 +198,10 @@ it('groups recognizers simultaneously when gestureMode is simultaneous', () => { expect(mocked.Exclusive).not.toHaveBeenCalled(); expect(mocked.Simultaneous).toHaveBeenCalledTimes(2); }); + +it('cleans up through onFinalize, which is the only callback guaranteed to run when a drag is cancelled', () => { + renderHook(() => useDragGesture(dragCallbacks, [])); + + const manual = mocked.Manual.mock.results[0]!.value as MockGesture; + expect(manual.handlers.onFinalize).toBe(dragCallbacks.onFinalize); +}); diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts index 48446565..f66c4ffb 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts @@ -17,7 +17,8 @@ const useDragGesture: GestureHandlerAdapter['useDragGesture'] = ( .onTouchesDown(callbacks.onTouchesDown) .onTouchesMove(callbacks.onTouchesMove) .onTouchesCancelled(callbacks.onTouchesCancelled) - .onTouchesUp(callbacks.onTouchesUp), + .onTouchesUp(callbacks.onTouchesUp) + .onFinalize(callbacks.onFinalize), // The dependency list is owned by the caller (useItemPanGesture). // eslint-disable-next-line react-hooks/exhaustive-deps deps diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts index 11f60dae..9dbbe3cb 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts @@ -44,6 +44,7 @@ const mocked = GestureHandler as unknown as { const useManualGesture = mocked.useManualGesture; const callbacks: ManualGestureCallbacks = { + onFinalize: jest.fn(), onTouchesCancelled: jest.fn(), onTouchesDown: jest.fn(), onTouchesMove: jest.fn(), @@ -173,3 +174,12 @@ it('returns the bare touch tracker when only touch callbacks are set (no recogni expect(useManualGesture).toHaveBeenCalledTimes(1); expect(mocked.useSimultaneousGestures).not.toHaveBeenCalled(); }); + +it('cleans up through onFinalize, which is the only callback guaranteed to run when a drag is cancelled', () => { + renderHook(() => useDragGesture(callbacks, [])); + + const [config] = useManualGesture.mock.calls[0] as [Record]; + expect(typeof config.onFinalize).toBe('function'); + (config.onFinalize as () => void)(); + expect(callbacks.onFinalize).toHaveBeenCalled(); +}); diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts index 3510e58c..3ff2b7c9 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts @@ -49,6 +49,10 @@ const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { const pendingActivation = useMutableValue(false); return useManualGesture({ + onFinalize: () => { + 'worklet'; + callbacks.onFinalize(); + }, onTouchesCancel: event => { 'worklet'; callbacks.onTouchesCancelled( diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts index 0aeecb9c..d48404ac 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -18,6 +18,11 @@ export type ManualGestureControl = { }; export type ManualGestureCallbacks = { + // Runs when the handler reaches a terminal state (END, FAILED or CANCELLED). + // Unlike the touch callbacks it is driven by handler state transitions, so it + // is the only callback guaranteed to run when a drag is ended by something + // other than the user lifting a finger - e.g. another gesture winning. + onFinalize: () => void; onTouchesCancelled: ( event: GestureTouchEvent, control: ManualGestureControl diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts index 7aa4c06f..ce86c4ea 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -12,6 +12,15 @@ export default function useItemPanGesture( return useDragGesture( { + // The handler reaching a terminal state is the only signal guaranteed to + // arrive when a drag ends without the user lifting a finger (another + // gesture winning, or the handler being cancelled). The touch callbacks + // below still run the same cleanup so a touch released before the + // activation delay cancels its pending activation timeout. + onFinalize: () => { + 'worklet'; + handleDragEnd(key, activationAnimationProgress); + }, onTouchesCancelled: (_event, control) => { 'worklet'; handleDragEnd(key, activationAnimationProgress); From ba27025ae27edd9b021b029a6dd7e4546bafaa27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 27 Jul 2026 09:13:22 +0200 Subject: [PATCH 2/5] Trim gesture finalize comments --- .../src/integrations/gesture-handler/types.ts | 6 ++---- .../src/providers/shared/hooks/useItemPanGesture.ts | 8 +++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts index d48404ac..dfd6fd19 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -18,10 +18,8 @@ export type ManualGestureControl = { }; export type ManualGestureCallbacks = { - // Runs when the handler reaches a terminal state (END, FAILED or CANCELLED). - // Unlike the touch callbacks it is driven by handler state transitions, so it - // is the only callback guaranteed to run when a drag is ended by something - // other than the user lifting a finger - e.g. another gesture winning. + // Driven by handler state, so unlike the touch callbacks it also runs when a + // drag ends without the user lifting a finger (e.g. another gesture wins). onFinalize: () => void; onTouchesCancelled: ( event: GestureTouchEvent, diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts index ce86c4ea..ab4d10b7 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -12,11 +12,9 @@ export default function useItemPanGesture( return useDragGesture( { - // The handler reaching a terminal state is the only signal guaranteed to - // arrive when a drag ends without the user lifting a finger (another - // gesture winning, or the handler being cancelled). The touch callbacks - // below still run the same cleanup so a touch released before the - // activation delay cancels its pending activation timeout. + // The touch callbacks below repeat this cleanup because they also cover a + // touch released before the activation delay, which never reaches a + // terminal handler state. onFinalize: () => { 'worklet'; handleDragEnd(key, activationAnimationProgress); From 7d36babcc89d26fccea0643312236767161f190e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 27 Jul 2026 14:04:57 +0200 Subject: [PATCH 3/5] Stop losing drag activations that no callback will recover onFinalize covers a drag that ends without the user lifting a finger, but a handler detached mid-gesture reaches no terminal state and emits no callback at all, so cleanup cannot depend on it alone. Three activation paths could silently strand the container with nothing on screen to show for it: - a handler re-registering mid-touch replayed onTouchesDown, and re-arming the activation timeout on every replay pushed activation out of reach, so the item never lifted; - the pending activation lived in a single container-wide slot that any item could clear, so a sibling ending its own gesture revoked the activation the finger was waiting on - onFinalize made that far more frequent; - the activation timeout returned without failing the gesture when the item had no measured position or dimensions. Give the pending activation an owner so only that item can cancel it, keep a replayed touch stream from restarting or killing a drag, fail the gesture instead of returning silently, and discard activation state that outlived the drag that created it. Also memoize the v3 gesture config so it is pushed to the native handler once rather than on every render. --- .../gesture-handler/adapters/v2.test.ts | 2 +- .../gesture-handler/adapters/v3.test.ts | 27 +- .../gesture-handler/adapters/v3.ts | 96 +++-- .../src/integrations/gesture-handler/index.ts | 1 + .../src/integrations/gesture-handler/types.ts | 4 +- .../src/providers/shared/DragProvider.ts | 80 +++- .../shared/dragActivationRecovery.test.tsx | 376 ++++++++++++++++++ .../shared/hooks/useItemPanGesture.ts | 35 +- 8 files changed, 556 insertions(+), 65 deletions(-) create mode 100644 packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts index 5e5ee81e..d05f3a47 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts @@ -199,7 +199,7 @@ it('groups recognizers simultaneously when gestureMode is simultaneous', () => { expect(mocked.Simultaneous).toHaveBeenCalledTimes(2); }); -it('cleans up through onFinalize, which is the only callback guaranteed to run when a drag is cancelled', () => { +it('also cleans up through onFinalize, which covers terminal states the touch callbacks miss', () => { renderHook(() => useDragGesture(dragCallbacks, [])); const manual = mocked.Manual.mock.results[0]!.value as MockGesture; diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts index 9dbbe3cb..f8bdbc3a 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts @@ -175,7 +175,7 @@ it('returns the bare touch tracker when only touch callbacks are set (no recogni expect(mocked.useSimultaneousGestures).not.toHaveBeenCalled(); }); -it('cleans up through onFinalize, which is the only callback guaranteed to run when a drag is cancelled', () => { +it('also cleans up through onFinalize, which covers terminal states the touch callbacks miss', () => { renderHook(() => useDragGesture(callbacks, [])); const [config] = useManualGesture.mock.calls[0] as [Record]; @@ -183,3 +183,28 @@ it('cleans up through onFinalize, which is the only callback guaranteed to run w (config.onFinalize as () => void)(); expect(callbacks.onFinalize).toHaveBeenCalled(); }); + +it('pushes the same config object on re-render, so the native handler is configured once', () => { + const { rerender } = renderHook(() => useDragGesture(callbacks, [])); + rerender(); + rerender(); + + expect(useManualGesture).toHaveBeenCalledTimes(3); + const configs = useManualGesture.mock.calls.map( + call => (call as [Record])[0] + ); + expect(configs[1]).toBe(configs[0]); + expect(configs[2]).toBe(configs[0]); +}); + +it('pushes a new config when the caller deps change', () => { + let dep = 'a'; + const { rerender } = renderHook(() => useDragGesture(callbacks, [dep])); + dep = 'b'; + rerender(); + + const configs = useManualGesture.mock.calls.map( + call => (call as [Record])[0] + ); + expect(configs[1]).not.toBe(configs[0]); +}); diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts index 3ff2b7c9..14fb3d3b 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts @@ -1,4 +1,8 @@ -import type { ManualGestureConfig } from 'react-native-gesture-handler'; +import { useMemo } from 'react'; +import type { + GestureTouchEvent, + ManualGestureConfig +} from 'react-native-gesture-handler'; import * as GestureHandler from 'react-native-gesture-handler'; import type { SharedValue } from 'react-native-reanimated'; @@ -44,49 +48,61 @@ function createControl( }; } -// v3 hooks re-apply config every render, so the caller's `deps` are unused. -const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { +// v3 memoizes on the config object identity alone, and re-applying a config +// natively resets it before updating it, which briefly drops `needsPointerData` +// and can swallow a touch event of a drag in progress. +const useDragGesture: GestureHandlerAdapter['useDragGesture'] = ( + callbacks, + deps +) => { const pendingActivation = useMutableValue(false); - return useManualGesture({ - onFinalize: () => { - 'worklet'; - callbacks.onFinalize(); - }, - onTouchesCancel: event => { - 'worklet'; - callbacks.onTouchesCancelled( - event, - createControl(event.handlerTag, pendingActivation) - ); - }, - onTouchesDown: event => { - 'worklet'; - pendingActivation.value = false; - callbacks.onTouchesDown( - event, - createControl(event.handlerTag, pendingActivation) - ); - }, - onTouchesMove: event => { - 'worklet'; - if (pendingActivation.value) { + const config = useMemo( + () => ({ + onFinalize: () => { + 'worklet'; + callbacks.onFinalize(); + }, + onTouchesCancel: (event: GestureTouchEvent) => { + 'worklet'; + callbacks.onTouchesCancelled( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesDown: (event: GestureTouchEvent) => { + 'worklet'; pendingActivation.value = false; - GestureStateManager.activate(event.handlerTag); + callbacks.onTouchesDown( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesMove: (event: GestureTouchEvent) => { + 'worklet'; + if (pendingActivation.value) { + pendingActivation.value = false; + GestureStateManager.activate(event.handlerTag); + } + callbacks.onTouchesMove( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesUp: (event: GestureTouchEvent) => { + 'worklet'; + callbacks.onTouchesUp( + event, + createControl(event.handlerTag, pendingActivation) + ); } - callbacks.onTouchesMove( - event, - createControl(event.handlerTag, pendingActivation) - ); - }, - onTouchesUp: event => { - 'worklet'; - callbacks.onTouchesUp( - event, - createControl(event.handlerTag, pendingActivation) - ); - } - }); + }), + // The dependency list is owned by the caller (useItemPanGesture). + // eslint-disable-next-line react-hooks/exhaustive-deps + deps + ); + + return useManualGesture(config); }; // Only the handlers that are passed create a gesture (and a native handler), so diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/index.ts b/packages/react-native-sortables/src/integrations/gesture-handler/index.ts index ca699795..6faaaed0 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/index.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/index.ts @@ -17,6 +17,7 @@ export const useTouchableGesture = adapter.useTouchableGesture; export { SortableGestureDetectorView } from './detector'; export type { GestureTouchEvent, + ManualGestureCallbacks, ManualGestureControl, SortableGesture, TouchableGestureConfig, diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts index dfd6fd19..cf182a7c 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -18,8 +18,8 @@ export type ManualGestureControl = { }; export type ManualGestureCallbacks = { - // Driven by handler state, so unlike the touch callbacks it also runs when a - // drag ends without the user lifting a finger (e.g. another gesture wins). + // Not a teardown hook: a handler detached mid-gesture reaches no terminal + // state and emits nothing. onFinalize: () => void; onTouchesCancelled: ( event: GestureTouchEvent, diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index 3dbaf7df..412f3843 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -46,10 +46,12 @@ type StateContextType = { dragStartTouchPosition: null | Vector; dragStartIndex: number; activationTimeoutId: number; + activationTimeoutKey: null | string; }; const INITIAL_STATE: StateContextType = { activationTimeoutId: -1, + activationTimeoutKey: null, dragStartIndex: -1, dragStartItemTouchOffset: null, dragStartTouchPosition: null, @@ -377,6 +379,28 @@ const { DragProvider, useDragContext } = createProvider('Drag')< ] ); + const discardAbandonedDrag = useCallback( + (activationAnimationProgress: SharedValue) => { + 'worklet'; + clearAnimatedTimeout(context.value.activationTimeoutId); + context.value.activationTimeoutKey = null; + activeItemKey.value = null; + activationState.value = DragActivationState.INACTIVE; + activeItemDropped.value = true; + activationAnimationProgress.value = 0; + activeAnimationProgress.value = 0; + inactiveAnimationProgress.value = 0; + }, + [ + activationState, + activeAnimationProgress, + activeItemDropped, + activeItemKey, + context, + inactiveAnimationProgress + ] + ); + const handleTouchStart = useCallback( ( e: GestureTouchEvent, @@ -387,6 +411,30 @@ const { DragProvider, useDragContext } = createProvider('Drag')< ) => { 'worklet'; const touch = e.allTouches[0]; + + if (activeItemKey.value === key) { + // No activation animation means the drag belongs to a previous mount, + // so nothing else will ever clear it. + if (activationAnimationProgress.value === 0) { + discardAbandonedDrag(activationAnimationProgress); + } else { + // A handler re-registering mid-drag, or a second finger. Neither is a + // new press, and failing here would kill the drag. + return; + } + } + + // Nothing is left to animate this progress down, so the gate below would + // reject the item forever. + const isProgressOutlivingItsDrag = + activeItemKey.value === null && + activeItemDropped.value && + activationAnimationProgress.value > 0; + + if (isProgressOutlivingItsDrag) { + discardAbandonedDrag(activationAnimationProgress); + } + if ( !touch || // Sorting is disabled @@ -406,14 +454,19 @@ const { DragProvider, useDragContext } = createProvider('Drag')< currentTouch.value = touch; activationState.value = DragActivationState.TOUCHED; + // A handler re-registering mid-touch replays onTouchesDown for the finger + // already down, and re-arming would push activation further away forever. + if (ctx.activationTimeoutKey === key) { + return; + } + clearAnimatedTimeout(ctx.activationTimeoutId); + ctx.activationTimeoutKey = key; // Start handling touch after a delay to prevent accidental activation // e.g. while scrolling the ScrollView ctx.activationTimeoutId = setAnimatedTimeout(() => { - if (!usesAbsoluteLayout.value) { - return; - } + ctx.activationTimeoutKey = null; const itemPosition = itemLayoutPositions.value[key]; const itemDimensions = getItemDimensions( @@ -422,7 +475,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< itemHeights.value ); - if (!itemPosition || !itemDimensions) { + if (!usesAbsoluteLayout.value || !itemPosition || !itemDimensions) { + fail(); return; } @@ -437,10 +491,12 @@ const { DragProvider, useDragContext } = createProvider('Drag')< }, dragActivationDelay.value); }, [ + activeItemDropped, activeItemKey, activationState, context, currentTouch, + discardAbandonedDrag, dragActivationDelay, handleDragStart, itemHeights, @@ -507,18 +563,26 @@ const { DragProvider, useDragContext } = createProvider('Drag')< const handleDragEnd = useCallback( (key: string, activationAnimationProgress: SharedValue) => { 'worklet'; - if (activeItemKey.value && activeItemKey.value !== key) { + const ctx = context.value; + // Only the pressed item may cancel its own pending activation, or any + // sibling ending its gesture silently revokes it. + const ownsPendingActivation = ctx.activationTimeoutKey === key; + const endsActiveDrag = activeItemKey.value === key; + + if (!ownsPendingActivation && !endsActiveDrag) { return; } - const ctx = context.value; - clearAnimatedTimeout(ctx.activationTimeoutId); + if (ownsPendingActivation) { + clearAnimatedTimeout(ctx.activationTimeoutId); + ctx.activationTimeoutKey = null; + } ctx.touchStartTouch = null; currentTouch.value = null; activationState.value = DragActivationState.INACTIVE; - if (activeItemKey.value === null) { + if (!endsActiveDrag) { return; } if (activeHandleMeasurements) { diff --git a/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx b/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx new file mode 100644 index 00000000..2fdbdf1d --- /dev/null +++ b/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx @@ -0,0 +1,376 @@ +import { act, render } from '@testing-library/react-native'; +import { View } from 'react-native'; +import type { GestureTouchEvent } from 'react-native-gesture-handler'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import type { SharedValue } from 'react-native-reanimated'; +import { makeMutable } from 'react-native-reanimated'; + +import Sortable from '../../index'; +import type { DragContextType } from '../../types'; +import { DragActivationState } from '../../types'; +import { useCommonValuesContext } from './CommonValuesProvider'; +import { useDragContext } from './DragProvider'; +import { useItemContext } from './ItemContextProvider'; + +const KEYS = ['a', 'b', 'c']; +const ITEM_SIZE = 100; + +type Common = ReturnType; + +let common: Common; +let drag: DragContextType; +let progresses: Record>; + +function ItemProbe({ itemKey }: { itemKey: string }) { + const { activationAnimationProgress } = useItemContext(); + common = useCommonValuesContext(); + drag = useDragContext(); + // The item context exposes it read-only; the gesture callbacks pass the very + // same mutable value to the drag handlers. + progresses[itemKey] = activationAnimationProgress as SharedValue; + return ; +} + +const onDragEnd = jest.fn(); +const onActiveItemDropped = jest.fn(); + +function renderGrid() { + progresses = {}; + const tree = render( + + } + onActiveItemDropped={onActiveItemDropped} + onDragEnd={onDragEnd} + /> + + ); + + // The absolute layout latch and the measurements are normally set by + // onLayout, which never fires under jest - prime them by hand so activation + // is not blocked by missing geometry. + act(() => { + common.sortEnabled.value = true; + common.usesAbsoluteLayout.value = true; + common.itemWidths.value = ITEM_SIZE; + common.itemHeights.value = ITEM_SIZE; + common.itemLayoutPositions.value = { + a: { x: 0, y: 0 }, + b: { x: ITEM_SIZE, y: 0 }, + c: { x: 0, y: ITEM_SIZE } + }; + }); + + return tree; +} + +// Shaped like the event gesture-handler delivers to onTouchesDown. +function touchEvent(x = 10, y = 10): GestureTouchEvent { + return { + allTouches: [{ absoluteX: x, absoluteY: y, id: 0, x, y }], + changedTouches: [], + handlerTag: 1, + numberOfTouches: 1, + state: 2 + } as unknown as GestureTouchEvent; +} + +// A second finger landing on a handler that already tracks one. +function twoFingerTouchEvent(): GestureTouchEvent { + return { + allTouches: [ + { absoluteX: 10, absoluteY: 10, id: 0, x: 10, y: 10 }, + { absoluteX: 30, absoluteY: 30, id: 1, x: 30, y: 30 } + ], + changedTouches: [], + handlerTag: 1, + numberOfTouches: 2, + state: 2 + } as unknown as GestureTouchEvent; +} + +/** A finger goes down on `key` and stays down past the activation delay. */ +function touchDown(key: string) { + const activate = jest.fn(); + const fail = jest.fn(); + const progress = progresses[key] ?? makeMutable(0); + + act(() => { + drag.handleTouchStart(touchEvent(), key, progress, activate, fail); + }); + act(() => { + jest.advanceTimersByTime(500); + }); + + return { activate, fail }; +} + +/** gesture-handler replaying onTouchesDown for a finger that is already down. */ +function replayTouchDown( + key: string, + activate: () => void = jest.fn(), + fail: () => void = jest.fn() +) { + act(() => { + jest.advanceTimersByTime(50); + drag.handleTouchStart(touchEvent(), key, progresses[key]!, activate, fail); + }); +} + +/** The finger is lifted normally - what onTouchesUp does. */ +function touchUp(key: string) { + act(() => { + drag.handleDragEnd(key, progresses[key]!); + jest.advanceTimersByTime(1000); + }); +} + +beforeEach(() => { + jest.useFakeTimers(); +}); + +it('activates an item on a normal press', () => { + renderGrid(); + + const { activate, fail } = touchDown('a'); + + expect(activate).toHaveBeenCalledTimes(1); + expect(fail).not.toHaveBeenCalled(); + expect(common.activeItemKey.value).toBe('a'); +}); + +it('activates again after a normal drag ends', () => { + renderGrid(); + + touchDown('a'); + touchUp('a'); + + expect(common.activeItemKey.value).toBeNull(); + expect(touchDown('a').activate).toHaveBeenCalledTimes(1); +}); + +// The reported "dead tile": no up/cancel callback is ever delivered, so the +// container still believes the item is being dragged. The replacement +// gesture's own touch up is what clears it. +it('recovers when the active item gesture dies without delivering a callback', () => { + renderGrid(); + + touchDown('a'); + expect(common.activeItemKey.value).toBe('a'); + + // The gesture dies here - no onTouchesUp, no onTouchesCancelled, no + // onFinalize (a native detach emits none of them). + + touchDown('a'); + touchUp('a'); + + expect(common.activeItemKey.value).toBeNull(); + expect(touchDown('a').activate).toHaveBeenCalledTimes(1); +}); + +// A remount hands the item a fresh activation progress while the container +// still points at it, so the container stops sorting with nothing on screen +// to show for it. +it('recovers an item that is still the active one after remounting', () => { + renderGrid(); + + touchDown('a'); + act(() => { + progresses.a!.value = 0; + }); + expect(common.activeItemKey.value).toBe('a'); + + expect(touchDown('a').activate).toHaveBeenCalledTimes(1); +}); + +// Discarding the drag on every replay would make dragging impossible under +// render churn. +it('keeps the drag running when its touch stream is replayed mid-drag', () => { + renderGrid(); + + touchDown('a'); + + for (let i = 0; i < 5; i++) replayTouchDown('a'); + + expect(common.activeItemKey.value).toBe('a'); + expect(common.activationState.value).toBe(DragActivationState.ACTIVE); + expect(progresses.a!.value).toBeGreaterThan(0); +}); + +// Both a touch callback and onFinalize can clean up the same gesture, and +// Android can even emit onFinalize twice, so repeats must be harmless. +it('reports a drag end once however many times the gesture cleans up', () => { + renderGrid(); + + touchDown('a'); + onDragEnd.mockClear(); + + touchUp('a'); + act(() => { + drag.handleDragEnd('a', progresses.a!); + drag.handleDragEnd('a', progresses.a!); + jest.advanceTimersByTime(1000); + }); + + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(common.activeItemKey.value).toBeNull(); +}); + +// Reporting a drag that never happened would commit a bogus reorder. +it('recovers without reporting a drag end to the caller', () => { + renderGrid(); + + touchDown('a'); + onDragEnd.mockClear(); + onActiveItemDropped.mockClear(); + + touchDown('a'); + + expect(onDragEnd).not.toHaveBeenCalled(); + expect(onActiveItemDropped).not.toHaveBeenCalled(); +}); + +it('recovers again when the gesture dies a second time', () => { + renderGrid(); + + for (let i = 0; i < 3; i++) { + touchDown('a'); + touchDown('a'); + touchUp('a'); + expect(common.activeItemKey.value).toBeNull(); + } + + expect(touchDown('a').activate).toHaveBeenCalledTimes(1); +}); + +it('keeps the drag alive when a second finger lands on the dragged item', () => { + renderGrid(); + + touchDown('a'); + + act(() => { + drag.handleTouchStart( + twoFingerTouchEvent(), + 'a', + progresses.a!, + jest.fn(), + jest.fn() + ); + jest.advanceTimersByTime(500); + }); + + expect(common.activeItemKey.value).toBe('a'); + expect(common.activationState.value).toBe(DragActivationState.ACTIVE); +}); + +// A progress too small to see still fails the gate, so the item looks +// completely normal and simply stops responding. +it('recovers an item left with a non-zero activation progress', () => { + renderGrid(); + + act(() => { + progresses.b!.value = 0.4; + }); + + expect(touchDown('b').activate).toHaveBeenCalledTimes(1); +}); + +it('recovers every item when they all kept a non-zero activation progress', () => { + renderGrid(); + + act(() => { + for (const key of KEYS) progresses[key]!.value = 0.05; + }); + + for (const key of KEYS) { + expect(touchDown(key).activate).toHaveBeenCalledTimes(1); + touchUp(key); + } +}); + +// activeItemDropped stays false for the whole drop animation. +it('still refuses to re-grab an item while its drop animation is running', () => { + renderGrid(); + + touchDown('a'); + act(() => { + drag.handleDragEnd('a', progresses.a!); + }); + + expect(common.activeItemDropped.value).toBe(false); + expect(progresses.a!.value).toBeGreaterThan(0); + expect(touchDown('a').activate).not.toHaveBeenCalled(); +}); + +// Re-arming on every replay pushes activation out of reach, and leaves no +// visual trace because no drag state is ever written. +it('activates on schedule while the touch stream is replayed', () => { + renderGrid(); + + const activate = jest.fn(); + const fail = jest.fn(); + act(() => { + drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); + }); + + // Replays arriving faster than the 200ms activation delay, for ten times as + // long as that delay - the finger is down the whole time. + for (let i = 0; i < 40; i++) replayTouchDown('a', activate, fail); + + expect(activate).toHaveBeenCalled(); + expect(common.activeItemKey.value).toBe('a'); +}); + +// onFinalize makes handleDragEnd run on every failed touch, so a sibling +// revoking the pending activation would fire often. +it('keeps a pending activation when another item ends its gesture', () => { + renderGrid(); + + const activate = jest.fn(); + const fail = jest.fn(); + act(() => { + drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); + }); + + act(() => { + drag.handleDragEnd('b', progresses.b!); + jest.advanceTimersByTime(500); + }); + + expect(activate).toHaveBeenCalledTimes(1); + expect(common.activeItemKey.value).toBe('a'); +}); + +it('fails the gesture when the item has no measured position', () => { + renderGrid(); + + const { activate, fail } = touchDown('never-measured'); + + expect(activate).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalled(); +}); + +// Otherwise the timeout later activates an item with no finger down. +it('does not activate an item whose touch died before the activation delay', () => { + renderGrid(); + + const activate = jest.fn(); + const fail = jest.fn(); + act(() => { + drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); + }); + + // The gesture is torn down here, before the delay elapses. + act(() => { + drag.handleDragEnd('a', progresses.a!); + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(activate).not.toHaveBeenCalled(); + expect(common.activeItemKey.value).toBeNull(); +}); diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts index ab4d10b7..b61fb63f 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -1,5 +1,7 @@ +import { useMemo } from 'react'; import type { SharedValue } from 'react-native-reanimated'; +import type { ManualGestureCallbacks } from '../../../integrations/gesture-handler'; import { useDragGesture } from '../../../integrations/gesture-handler'; import { useDragContext } from '../DragProvider'; @@ -10,11 +12,21 @@ export default function useItemPanGesture( const { handleDragEnd, handleTouchesMove, handleTouchStart } = useDragContext(); - return useDragGesture( - { - // The touch callbacks below repeat this cleanup because they also cover a - // touch released before the activation delay, which never reaches a - // terminal handler state. + const deps = [ + handleDragEnd, + handleTouchStart, + handleTouchesMove, + key, + activationAnimationProgress + ]; + + const callbacks = useMemo( + () => ({ + // The touch callbacks repeat this cleanup because a touch that ends + // before the item activates never reaches onFinalize (measured on iOS: a + // tap emits onTouchesDown and onTouchesUp only). Without them the pending + // activation survives the tap and lifts the item with no finger down. + // Both may run for one gesture, so handleDragEnd has to stay idempotent. onFinalize: () => { 'worklet'; handleDragEnd(key, activationAnimationProgress); @@ -43,13 +55,10 @@ export default function useItemPanGesture( handleDragEnd(key, activationAnimationProgress); control.end(); } - }, - [ - handleDragEnd, - handleTouchStart, - handleTouchesMove, - key, - activationAnimationProgress - ] + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + deps ); + + return useDragGesture(callbacks, deps); } From ac3bfd0ea16d91aa0b7486cef95b8a63d267447f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 27 Jul 2026 14:09:55 +0200 Subject: [PATCH 4/5] Trim activation recovery comments and drop the redundant idempotency test --- .../gesture-handler/adapters/v3.ts | 5 ++--- .../src/providers/shared/DragProvider.ts | 13 +++++-------- .../shared/dragActivationRecovery.test.tsx | 19 ------------------- .../shared/hooks/useItemPanGesture.ts | 7 ++----- 4 files changed, 9 insertions(+), 35 deletions(-) diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts index 14fb3d3b..cb0b602d 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts @@ -48,9 +48,8 @@ function createControl( }; } -// v3 memoizes on the config object identity alone, and re-applying a config -// natively resets it before updating it, which briefly drops `needsPointerData` -// and can swallow a touch event of a drag in progress. +// Re-applying a config natively resets it first, which briefly drops +// `needsPointerData` and can swallow a touch event of a drag in progress. const useDragGesture: GestureHandlerAdapter['useDragGesture'] = ( callbacks, deps diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index 412f3843..c156a749 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -413,19 +413,16 @@ const { DragProvider, useDragContext } = createProvider('Drag')< const touch = e.allTouches[0]; if (activeItemKey.value === key) { - // No activation animation means the drag belongs to a previous mount, - // so nothing else will ever clear it. + // The drag belongs to a previous mount, so nothing else will clear it. if (activationAnimationProgress.value === 0) { discardAbandonedDrag(activationAnimationProgress); } else { - // A handler re-registering mid-drag, or a second finger. Neither is a - // new press, and failing here would kill the drag. + // Not a new press, and failing here would kill the drag. return; } } - // Nothing is left to animate this progress down, so the gate below would - // reject the item forever. + // Nothing is left to animate this progress down. const isProgressOutlivingItsDrag = activeItemKey.value === null && activeItemDropped.value && @@ -454,8 +451,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< currentTouch.value = touch; activationState.value = DragActivationState.TOUCHED; - // A handler re-registering mid-touch replays onTouchesDown for the finger - // already down, and re-arming would push activation further away forever. + // A re-registering handler replays onTouchesDown for the finger already + // down, and re-arming would push activation further away forever. if (ctx.activationTimeoutKey === key) { return; } diff --git a/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx b/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx index 2fdbdf1d..dbc84073 100644 --- a/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx +++ b/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx @@ -199,25 +199,6 @@ it('keeps the drag running when its touch stream is replayed mid-drag', () => { expect(progresses.a!.value).toBeGreaterThan(0); }); -// Both a touch callback and onFinalize can clean up the same gesture, and -// Android can even emit onFinalize twice, so repeats must be harmless. -it('reports a drag end once however many times the gesture cleans up', () => { - renderGrid(); - - touchDown('a'); - onDragEnd.mockClear(); - - touchUp('a'); - act(() => { - drag.handleDragEnd('a', progresses.a!); - drag.handleDragEnd('a', progresses.a!); - jest.advanceTimersByTime(1000); - }); - - expect(onDragEnd).toHaveBeenCalledTimes(1); - expect(common.activeItemKey.value).toBeNull(); -}); - // Reporting a drag that never happened would commit a bogus reorder. it('recovers without reporting a drag end to the caller', () => { renderGrid(); diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts index b61fb63f..3f6d0111 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -22,11 +22,8 @@ export default function useItemPanGesture( const callbacks = useMemo( () => ({ - // The touch callbacks repeat this cleanup because a touch that ends - // before the item activates never reaches onFinalize (measured on iOS: a - // tap emits onTouchesDown and onTouchesUp only). Without them the pending - // activation survives the tap and lifts the item with no finger down. - // Both may run for one gesture, so handleDragEnd has to stay idempotent. + // A touch that ends before the item activates never reaches onFinalize, + // so the touch callbacks below repeat this cleanup. onFinalize: () => { 'worklet'; handleDragEnd(key, activationAnimationProgress); From 64424a5b3f3fb05ab05986cda8f59ab1fc30e27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 27 Jul 2026 14:41:53 +0200 Subject: [PATCH 5/5] Drop the drag activation recovery test file --- .../shared/dragActivationRecovery.test.tsx | 357 ------------------ 1 file changed, 357 deletions(-) delete mode 100644 packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx diff --git a/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx b/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx deleted file mode 100644 index dbc84073..00000000 --- a/packages/react-native-sortables/src/providers/shared/dragActivationRecovery.test.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { act, render } from '@testing-library/react-native'; -import { View } from 'react-native'; -import type { GestureTouchEvent } from 'react-native-gesture-handler'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import type { SharedValue } from 'react-native-reanimated'; -import { makeMutable } from 'react-native-reanimated'; - -import Sortable from '../../index'; -import type { DragContextType } from '../../types'; -import { DragActivationState } from '../../types'; -import { useCommonValuesContext } from './CommonValuesProvider'; -import { useDragContext } from './DragProvider'; -import { useItemContext } from './ItemContextProvider'; - -const KEYS = ['a', 'b', 'c']; -const ITEM_SIZE = 100; - -type Common = ReturnType; - -let common: Common; -let drag: DragContextType; -let progresses: Record>; - -function ItemProbe({ itemKey }: { itemKey: string }) { - const { activationAnimationProgress } = useItemContext(); - common = useCommonValuesContext(); - drag = useDragContext(); - // The item context exposes it read-only; the gesture callbacks pass the very - // same mutable value to the drag handlers. - progresses[itemKey] = activationAnimationProgress as SharedValue; - return ; -} - -const onDragEnd = jest.fn(); -const onActiveItemDropped = jest.fn(); - -function renderGrid() { - progresses = {}; - const tree = render( - - } - onActiveItemDropped={onActiveItemDropped} - onDragEnd={onDragEnd} - /> - - ); - - // The absolute layout latch and the measurements are normally set by - // onLayout, which never fires under jest - prime them by hand so activation - // is not blocked by missing geometry. - act(() => { - common.sortEnabled.value = true; - common.usesAbsoluteLayout.value = true; - common.itemWidths.value = ITEM_SIZE; - common.itemHeights.value = ITEM_SIZE; - common.itemLayoutPositions.value = { - a: { x: 0, y: 0 }, - b: { x: ITEM_SIZE, y: 0 }, - c: { x: 0, y: ITEM_SIZE } - }; - }); - - return tree; -} - -// Shaped like the event gesture-handler delivers to onTouchesDown. -function touchEvent(x = 10, y = 10): GestureTouchEvent { - return { - allTouches: [{ absoluteX: x, absoluteY: y, id: 0, x, y }], - changedTouches: [], - handlerTag: 1, - numberOfTouches: 1, - state: 2 - } as unknown as GestureTouchEvent; -} - -// A second finger landing on a handler that already tracks one. -function twoFingerTouchEvent(): GestureTouchEvent { - return { - allTouches: [ - { absoluteX: 10, absoluteY: 10, id: 0, x: 10, y: 10 }, - { absoluteX: 30, absoluteY: 30, id: 1, x: 30, y: 30 } - ], - changedTouches: [], - handlerTag: 1, - numberOfTouches: 2, - state: 2 - } as unknown as GestureTouchEvent; -} - -/** A finger goes down on `key` and stays down past the activation delay. */ -function touchDown(key: string) { - const activate = jest.fn(); - const fail = jest.fn(); - const progress = progresses[key] ?? makeMutable(0); - - act(() => { - drag.handleTouchStart(touchEvent(), key, progress, activate, fail); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - - return { activate, fail }; -} - -/** gesture-handler replaying onTouchesDown for a finger that is already down. */ -function replayTouchDown( - key: string, - activate: () => void = jest.fn(), - fail: () => void = jest.fn() -) { - act(() => { - jest.advanceTimersByTime(50); - drag.handleTouchStart(touchEvent(), key, progresses[key]!, activate, fail); - }); -} - -/** The finger is lifted normally - what onTouchesUp does. */ -function touchUp(key: string) { - act(() => { - drag.handleDragEnd(key, progresses[key]!); - jest.advanceTimersByTime(1000); - }); -} - -beforeEach(() => { - jest.useFakeTimers(); -}); - -it('activates an item on a normal press', () => { - renderGrid(); - - const { activate, fail } = touchDown('a'); - - expect(activate).toHaveBeenCalledTimes(1); - expect(fail).not.toHaveBeenCalled(); - expect(common.activeItemKey.value).toBe('a'); -}); - -it('activates again after a normal drag ends', () => { - renderGrid(); - - touchDown('a'); - touchUp('a'); - - expect(common.activeItemKey.value).toBeNull(); - expect(touchDown('a').activate).toHaveBeenCalledTimes(1); -}); - -// The reported "dead tile": no up/cancel callback is ever delivered, so the -// container still believes the item is being dragged. The replacement -// gesture's own touch up is what clears it. -it('recovers when the active item gesture dies without delivering a callback', () => { - renderGrid(); - - touchDown('a'); - expect(common.activeItemKey.value).toBe('a'); - - // The gesture dies here - no onTouchesUp, no onTouchesCancelled, no - // onFinalize (a native detach emits none of them). - - touchDown('a'); - touchUp('a'); - - expect(common.activeItemKey.value).toBeNull(); - expect(touchDown('a').activate).toHaveBeenCalledTimes(1); -}); - -// A remount hands the item a fresh activation progress while the container -// still points at it, so the container stops sorting with nothing on screen -// to show for it. -it('recovers an item that is still the active one after remounting', () => { - renderGrid(); - - touchDown('a'); - act(() => { - progresses.a!.value = 0; - }); - expect(common.activeItemKey.value).toBe('a'); - - expect(touchDown('a').activate).toHaveBeenCalledTimes(1); -}); - -// Discarding the drag on every replay would make dragging impossible under -// render churn. -it('keeps the drag running when its touch stream is replayed mid-drag', () => { - renderGrid(); - - touchDown('a'); - - for (let i = 0; i < 5; i++) replayTouchDown('a'); - - expect(common.activeItemKey.value).toBe('a'); - expect(common.activationState.value).toBe(DragActivationState.ACTIVE); - expect(progresses.a!.value).toBeGreaterThan(0); -}); - -// Reporting a drag that never happened would commit a bogus reorder. -it('recovers without reporting a drag end to the caller', () => { - renderGrid(); - - touchDown('a'); - onDragEnd.mockClear(); - onActiveItemDropped.mockClear(); - - touchDown('a'); - - expect(onDragEnd).not.toHaveBeenCalled(); - expect(onActiveItemDropped).not.toHaveBeenCalled(); -}); - -it('recovers again when the gesture dies a second time', () => { - renderGrid(); - - for (let i = 0; i < 3; i++) { - touchDown('a'); - touchDown('a'); - touchUp('a'); - expect(common.activeItemKey.value).toBeNull(); - } - - expect(touchDown('a').activate).toHaveBeenCalledTimes(1); -}); - -it('keeps the drag alive when a second finger lands on the dragged item', () => { - renderGrid(); - - touchDown('a'); - - act(() => { - drag.handleTouchStart( - twoFingerTouchEvent(), - 'a', - progresses.a!, - jest.fn(), - jest.fn() - ); - jest.advanceTimersByTime(500); - }); - - expect(common.activeItemKey.value).toBe('a'); - expect(common.activationState.value).toBe(DragActivationState.ACTIVE); -}); - -// A progress too small to see still fails the gate, so the item looks -// completely normal and simply stops responding. -it('recovers an item left with a non-zero activation progress', () => { - renderGrid(); - - act(() => { - progresses.b!.value = 0.4; - }); - - expect(touchDown('b').activate).toHaveBeenCalledTimes(1); -}); - -it('recovers every item when they all kept a non-zero activation progress', () => { - renderGrid(); - - act(() => { - for (const key of KEYS) progresses[key]!.value = 0.05; - }); - - for (const key of KEYS) { - expect(touchDown(key).activate).toHaveBeenCalledTimes(1); - touchUp(key); - } -}); - -// activeItemDropped stays false for the whole drop animation. -it('still refuses to re-grab an item while its drop animation is running', () => { - renderGrid(); - - touchDown('a'); - act(() => { - drag.handleDragEnd('a', progresses.a!); - }); - - expect(common.activeItemDropped.value).toBe(false); - expect(progresses.a!.value).toBeGreaterThan(0); - expect(touchDown('a').activate).not.toHaveBeenCalled(); -}); - -// Re-arming on every replay pushes activation out of reach, and leaves no -// visual trace because no drag state is ever written. -it('activates on schedule while the touch stream is replayed', () => { - renderGrid(); - - const activate = jest.fn(); - const fail = jest.fn(); - act(() => { - drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); - }); - - // Replays arriving faster than the 200ms activation delay, for ten times as - // long as that delay - the finger is down the whole time. - for (let i = 0; i < 40; i++) replayTouchDown('a', activate, fail); - - expect(activate).toHaveBeenCalled(); - expect(common.activeItemKey.value).toBe('a'); -}); - -// onFinalize makes handleDragEnd run on every failed touch, so a sibling -// revoking the pending activation would fire often. -it('keeps a pending activation when another item ends its gesture', () => { - renderGrid(); - - const activate = jest.fn(); - const fail = jest.fn(); - act(() => { - drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); - }); - - act(() => { - drag.handleDragEnd('b', progresses.b!); - jest.advanceTimersByTime(500); - }); - - expect(activate).toHaveBeenCalledTimes(1); - expect(common.activeItemKey.value).toBe('a'); -}); - -it('fails the gesture when the item has no measured position', () => { - renderGrid(); - - const { activate, fail } = touchDown('never-measured'); - - expect(activate).not.toHaveBeenCalled(); - expect(fail).toHaveBeenCalled(); -}); - -// Otherwise the timeout later activates an item with no finger down. -it('does not activate an item whose touch died before the activation delay', () => { - renderGrid(); - - const activate = jest.fn(); - const fail = jest.fn(); - act(() => { - drag.handleTouchStart(touchEvent(), 'a', progresses.a!, activate, fail); - }); - - // The gesture is torn down here, before the delay elapses. - act(() => { - drag.handleDragEnd('a', progresses.a!); - }); - - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(activate).not.toHaveBeenCalled(); - expect(common.activeItemKey.value).toBeNull(); -});