diff --git a/package-lock.json b/package-lock.json index 6c60a12..b1df982 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "pulse", "version": "2.0.0", + "hasInstallScript": true, "dependencies": { "@expo/ui": "~57.0.10", "@react-native-async-storage/async-storage": "2.2.0", @@ -72,6 +73,7 @@ "eslint-config-expo": "~57.0.1", "eslint-config-prettier": "^10.1.8", "jest": "~29.7.0", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "typescript": "~6.0.3" } @@ -5471,6 +5473,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -9170,6 +9179,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -9228,6 +9247,21 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11157,6 +11191,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11176,6 +11230,29 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -11202,6 +11279,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12873,6 +12960,46 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -14969,6 +15096,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15790,6 +15927,16 @@ "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 8eb28bc..ef173d6 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "eslint-config-expo": "~57.0.1", "eslint-config-prettier": "^10.1.8", "jest": "~29.7.0", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "typescript": "~6.0.3" }, @@ -83,6 +84,7 @@ } }, "scripts": { + "postinstall": "patch-package", "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", diff --git a/patches/react-native-sortables+1.10.0.patch b/patches/react-native-sortables+1.10.0.patch new file mode 100644 index 0000000..63dd053 --- /dev/null +++ b/patches/react-native-sortables+1.10.0.patch @@ -0,0 +1,438 @@ +diff --git a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts +index 2143f65..d05f3a4 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.test.ts ++++ b/node_modules/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('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; ++ expect(manual.handlers.onFinalize).toBe(dragCallbacks.onFinalize); ++}); +diff --git a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts +index 4844656..f66c4ff 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts ++++ b/node_modules/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/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts +index 11f60da..f8bdbc3 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.test.ts ++++ b/node_modules/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,37 @@ it('returns the bare touch tracker when only touch callbacks are set (no recogni + expect(useManualGesture).toHaveBeenCalledTimes(1); + expect(mocked.useSimultaneousGestures).not.toHaveBeenCalled(); + }); ++ ++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]; ++ expect(typeof config.onFinalize).toBe('function'); ++ (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/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts +index 3510e58..cb0b602 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts ++++ b/node_modules/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,45 +48,60 @@ function createControl( + }; + } + +-// v3 hooks re-apply config every render, so the caller's `deps` are unused. +-const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { ++// 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 ++) => { + const pendingActivation = useMutableValue(false); + +- return useManualGesture({ +- 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/node_modules/react-native-sortables/src/integrations/gesture-handler/index.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/index.ts +index ca69979..6faaaed 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/index.ts ++++ b/node_modules/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/node_modules/react-native-sortables/src/integrations/gesture-handler/types.ts b/node_modules/react-native-sortables/src/integrations/gesture-handler/types.ts +index 0aeecb9..cf182a7 100644 +--- a/node_modules/react-native-sortables/src/integrations/gesture-handler/types.ts ++++ b/node_modules/react-native-sortables/src/integrations/gesture-handler/types.ts +@@ -18,6 +18,9 @@ export type ManualGestureControl = { + }; + + export type ManualGestureCallbacks = { ++ // Not a teardown hook: a handler detached mid-gesture reaches no terminal ++ // state and emits nothing. ++ onFinalize: () => void; + onTouchesCancelled: ( + event: GestureTouchEvent, + control: ManualGestureControl +diff --git a/node_modules/react-native-sortables/src/providers/shared/DragProvider.ts b/node_modules/react-native-sortables/src/providers/shared/DragProvider.ts +index 3dbaf7d..c156a74 100644 +--- a/node_modules/react-native-sortables/src/providers/shared/DragProvider.ts ++++ b/node_modules/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,27 @@ const { DragProvider, useDragContext } = createProvider('Drag')< + ) => { + 'worklet'; + const touch = e.allTouches[0]; ++ ++ if (activeItemKey.value === key) { ++ // The drag belongs to a previous mount, so nothing else will clear it. ++ if (activationAnimationProgress.value === 0) { ++ discardAbandonedDrag(activationAnimationProgress); ++ } else { ++ // Not a new press, and failing here would kill the drag. ++ return; ++ } ++ } ++ ++ // Nothing is left to animate this progress down. ++ const isProgressOutlivingItsDrag = ++ activeItemKey.value === null && ++ activeItemDropped.value && ++ activationAnimationProgress.value > 0; ++ ++ if (isProgressOutlivingItsDrag) { ++ discardAbandonedDrag(activationAnimationProgress); ++ } ++ + if ( + !touch || + // Sorting is disabled +@@ -406,14 +451,19 @@ const { DragProvider, useDragContext } = createProvider('Drag')< + currentTouch.value = touch; + activationState.value = DragActivationState.TOUCHED; + ++ // 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; ++ } ++ + 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 +472,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< + itemHeights.value + ); + +- if (!itemPosition || !itemDimensions) { ++ if (!usesAbsoluteLayout.value || !itemPosition || !itemDimensions) { ++ fail(); + return; + } + +@@ -437,10 +488,12 @@ const { DragProvider, useDragContext } = createProvider('Drag')< + }, dragActivationDelay.value); + }, + [ ++ activeItemDropped, + activeItemKey, + activationState, + context, + currentTouch, ++ discardAbandonedDrag, + dragActivationDelay, + handleDragStart, + itemHeights, +@@ -507,18 +560,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/node_modules/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/node_modules/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +index 7aa4c06..3f6d011 100644 +--- a/node_modules/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts ++++ b/node_modules/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,8 +12,22 @@ export default function useItemPanGesture( + const { handleDragEnd, handleTouchesMove, handleTouchStart } = + useDragContext(); + +- return useDragGesture( +- { ++ const deps = [ ++ handleDragEnd, ++ handleTouchStart, ++ handleTouchesMove, ++ key, ++ activationAnimationProgress ++ ]; ++ ++ const callbacks = useMemo( ++ () => ({ ++ // A touch that ends before the item activates never reaches onFinalize, ++ // so the touch callbacks below repeat this cleanup. ++ onFinalize: () => { ++ 'worklet'; ++ handleDragEnd(key, activationAnimationProgress); ++ }, + onTouchesCancelled: (_event, control) => { + 'worklet'; + handleDragEnd(key, activationAnimationProgress); +@@ -36,13 +52,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); + }