From f1906c4fa0821911f1e2fac34be4e6adbba94a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 27 Jun 2026 22:33:35 +0200 Subject: [PATCH 1/6] Add gesture-handler v3 hook adapter so drag survives screen re-attach Drags could get stuck or stop starting after a screen was detached and re-attached on iOS + New Architecture (e.g. bottom tabs with detachInactiveScreens=false) - a gesture-handler v2 limitation (#349). The new gesture-handler integration uses the v3 hook API when installed and falls back to the v2 imperative builder otherwise, selected once at module load. Non-breaking: the peer range stays >=2.0.0 and public types use only gesture names exported by both majors. --- README.md | 13 +- packages/react-native-sortables/package.json | 2 +- .../src/components/shared/CustomHandle.tsx | 8 +- .../shared/DraggableView/ActiveItemPortal.tsx | 4 +- .../shared/DraggableView/DraggableView.tsx | 2 +- .../components/shared/SortableTouchable.tsx | 69 ++----- .../gesture-handler/adapters/types.ts | 25 +++ .../gesture-handler/adapters/v2.ts | 127 ++++++++++++ .../gesture-handler/adapters/v3.ts | 186 ++++++++++++++++++ .../integrations/gesture-handler/detector.tsx | 26 +++ .../src/integrations/gesture-handler/index.ts | 29 +++ .../src/integrations/gesture-handler/types.ts | 62 ++++++ .../shared/hooks/useItemPanGesture.ts | 53 ++--- .../src/types/providers/shared.ts | 8 +- yarn.lock | 15 +- 15 files changed, 533 insertions(+), 96 deletions(-) create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/detector.tsx create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/index.ts create mode 100644 packages/react-native-sortables/src/integrations/gesture-handler/types.ts diff --git a/README.md b/README.md index 7b45ab86..1aaabc76 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,21 @@ yarn add react-native-sortables This library is built with: - [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) (version 3.x, 4.x) -- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) (version 2.x) +- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) (version 2.x, 3.x) Make sure to follow their installation instructions for your project. +> [!NOTE] +> On iOS + New Architecture, gesture-handler **2.x** has a known limitation: a +> sortable stops recognizing drags after its screen is detached and re-attached +> (for example bottom tabs with `detachInactiveScreens={false}`), which can make +> a dragged item get "stuck" - see +> [#349](https://github.com/MatiPl01/react-native-sortables/issues/349). This is +> not fixable on the v2 API and is resolved by upgrading to +> **gesture-handler 3** (which the library uses automatically when installed; v3 +> requires the New Architecture). On the Old Architecture (Paper) the issue does +> not occur, so gesture-handler 2.x remains the right choice there. + ## Quick Start ```tsx diff --git a/packages/react-native-sortables/package.json b/packages/react-native-sortables/package.json index 56bf60f4..241e6ef9 100644 --- a/packages/react-native-sortables/package.json +++ b/packages/react-native-sortables/package.json @@ -25,7 +25,7 @@ "react": "19.2.3", "react-native": "0.85.3", "react-native-builder-bob": "0.40.13", - "react-native-gesture-handler": "2.31.1", + "react-native-gesture-handler": "3.0.2", "react-native-reanimated": "4.5.0", "react-native-worklets": "0.10.0", "semantic-release": "^24.0.0", diff --git a/packages/react-native-sortables/src/components/shared/CustomHandle.tsx b/packages/react-native-sortables/src/components/shared/CustomHandle.tsx index 9ca03e47..88e1d710 100644 --- a/packages/react-native-sortables/src/components/shared/CustomHandle.tsx +++ b/packages/react-native-sortables/src/components/shared/CustomHandle.tsx @@ -1,9 +1,12 @@ import { type PropsWithChildren, useCallback, useEffect } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { View } from 'react-native'; -import { GestureDetector } from 'react-native-gesture-handler'; import { runOnUI, useAnimatedRef } from 'react-native-reanimated'; +import { + GestureDetector, + useEnabledGesture +} from '../../integrations/gesture-handler'; import { useCustomHandleContext, useIsInPortalOutlet, @@ -61,6 +64,7 @@ function CustomHandleComponent({ const { registerHandle, updateActiveHandleMeasurements } = customHandleContext; const dragEnabled = mode === 'draggable'; + const handleGesture = useEnabledGesture(gesture, dragEnabled); useEffect(() => { return registerHandle(itemKey, handleRef, mode === 'fixed-order'); @@ -74,7 +78,7 @@ function CustomHandleComponent({ }, [itemKey, isActive, updateActiveHandleMeasurements]); return ( - + & { commonValuesContext: CommonValuesContextType; - gesture: GestureType; + gesture: SortableGesture; onTeleport: (isTeleported: boolean) => void; }; diff --git a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx index b2c38523..c0423f8e 100644 --- a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx +++ b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx @@ -1,12 +1,12 @@ import { Fragment, memo, useCallback, useEffect, useState } from 'react'; import type { LayoutChangeEvent } from 'react-native'; -import { GestureDetector } from 'react-native-gesture-handler'; import { LayoutAnimationConfig, runOnUI, useDerivedValue } from 'react-native-reanimated'; +import { GestureDetector } from '../../../integrations/gesture-handler'; import type { AnimatedStyleProp, LayoutAnimation diff --git a/packages/react-native-sortables/src/components/shared/SortableTouchable.tsx b/packages/react-native-sortables/src/components/shared/SortableTouchable.tsx index ecee36bd..a45ac286 100644 --- a/packages/react-native-sortables/src/components/shared/SortableTouchable.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableTouchable.tsx @@ -1,9 +1,11 @@ -import { type PropsWithChildren, useMemo } from 'react'; +import { type PropsWithChildren } from 'react'; import type { ViewProps } from 'react-native'; import { View } from 'react-native'; -import type { GestureType } from 'react-native-gesture-handler'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { + GestureDetector, + useTouchableGesture +} from '../../integrations/gesture-handler'; import { useItemContext } from '../../providers'; type SortableTouchableProps = PropsWithChildren< @@ -31,65 +33,16 @@ export default function SortableTouchable({ }: SortableTouchableProps) { const { gesture: externalGesture } = useItemContext(); - const gesture = useMemo(() => { - const decorate = (decoratedGesture: T): T => { - decoratedGesture - .simultaneousWithExternalGesture(externalGesture) - .runOnJS(true); - if ('maxDistance' in decoratedGesture) { - ( - decoratedGesture as { maxDistance: (distance: number) => GestureType } - ).maxDistance(failDistance); - } - return decoratedGesture; - }; - - const gestures = []; - - if (onTap) { - gestures.push(decorate(Gesture.Tap()).onStart(onTap)); - } - - if (onDoubleTap) { - gestures.push( - decorate(Gesture.Tap()).numberOfTaps(2).onStart(onDoubleTap) - ); - } - - if (onLongPress) { - gestures.push(decorate(Gesture.LongPress()).onStart(onLongPress)); - } - - if (onTouchesDown || onTouchesUp) { - // Reuse already added gesture if possible or create a manual gesture - // if there is no other gesture yet - if (!gestures.length) { - gestures.push(decorate(Gesture.Manual())); - } - - const lastGesture = gestures[gestures.length - 1]!; - - if (onTouchesDown) { - lastGesture.onTouchesDown(onTouchesDown); - } - if (onTouchesUp) { - lastGesture.onTouchesUp(onTouchesUp); - } - } - - return gestureMode === 'exclusive' - ? Gesture.Exclusive(...gestures) - : Gesture.Simultaneous(...gestures); - }, [ + const gesture = useTouchableGesture({ + externalGesture, failDistance, - onTap, + gestureMode, onDoubleTap, onLongPress, + onTap, onTouchesDown, - onTouchesUp, - externalGesture, - gestureMode - ]); + onTouchesUp + }); return ( diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts new file mode 100644 index 00000000..a9b4447f --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts @@ -0,0 +1,25 @@ +import type { + ManualGestureCallbacks, + SortableGesture, + TouchableGestureConfig +} from '../types'; + +/** + * Unified gesture API used across the library. Each gesture-handler major + * version provides its own implementation (v2 imperative builder, v3 hook API), + * selected once at module load based on which version is installed. + */ +export type GestureHandlerAdapter = { + /** Manual pan gesture for a draggable item. */ + useDragGesture: ( + callbacks: ManualGestureCallbacks, + deps: ReadonlyArray + ) => SortableGesture; + /** Applies an `enabled` flag to an existing gesture (custom handle). */ + useEnabledGesture: ( + gesture: SortableGesture, + enabled: boolean + ) => SortableGesture; + /** Composed tap / long-press / manual gesture for `SortableTouchable`. */ + useTouchableGesture: (config: TouchableGestureConfig) => SortableGesture; +}; 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 new file mode 100644 index 00000000..9915c259 --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v2.ts @@ -0,0 +1,127 @@ +import { useMemo } from 'react'; +import type { GestureType } from 'react-native-gesture-handler'; +import { Gesture } from 'react-native-gesture-handler'; + +import type { SortableGesture } from '../types'; +import type { GestureHandlerAdapter } from './types'; + +/** + * gesture-handler v2 (imperative builder) implementation. This is the legacy + * path used when react-native-gesture-handler < 3 is installed (e.g. on the Old + * Architecture / Paper, which gesture-handler v3 no longer supports). + * + * NOTE: on iOS + New Architecture this path is affected by the upstream + * gesture-handler limitation described in issue #349 (gestures stop being + * recognized after a screen is detached and re-attached, e.g. bottom tabs with + * `detachInactiveScreens={false}`). The limitation is not fixable here and is + * resolved only by the v3 hook API - see `./v3`. + */ + +const useDragGesture: GestureHandlerAdapter['useDragGesture'] = ( + callbacks, + deps +) => + useMemo( + () => + Gesture.Manual() + .onTouchesDown((event, manager) => + callbacks.onTouchesDown(event, manager) + ) + .onTouchesMove((event, manager) => + callbacks.onTouchesMove(event, manager) + ) + .onTouchesCancelled((event, manager) => + callbacks.onTouchesCancelled(event, manager) + ) + .onTouchesUp((event, manager) => callbacks.onTouchesUp(event, manager)), + // The caller (useItemPanGesture) controls the dependency list; `callbacks` + // is rebuilt whenever any of these change. + // eslint-disable-next-line react-hooks/exhaustive-deps + deps + ) as unknown as SortableGesture; + +const useEnabledGesture: GestureHandlerAdapter['useEnabledGesture'] = ( + gesture, + enabled +) => + (gesture as unknown as GestureType).enabled( + enabled + ) as unknown as SortableGesture; + +const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ + externalGesture, + failDistance, + gestureMode, + onDoubleTap, + onLongPress, + onTap, + onTouchesDown, + onTouchesUp +}) => + useMemo(() => { + const decorate = (decoratedGesture: T): T => { + decoratedGesture + .simultaneousWithExternalGesture( + externalGesture as unknown as GestureType + ) + .runOnJS(true); + if ('maxDistance' in decoratedGesture) { + ( + decoratedGesture as { maxDistance: (distance: number) => GestureType } + ).maxDistance(failDistance); + } + return decoratedGesture; + }; + + const gestures: Array = []; + + if (onTap) { + gestures.push(decorate(Gesture.Tap()).onStart(onTap)); + } + + if (onDoubleTap) { + gestures.push( + decorate(Gesture.Tap()).numberOfTaps(2).onStart(onDoubleTap) + ); + } + + if (onLongPress) { + gestures.push(decorate(Gesture.LongPress()).onStart(onLongPress)); + } + + if (onTouchesDown || onTouchesUp) { + // Reuse the already added gesture if possible or create a manual gesture + // if there is no other gesture yet + if (!gestures.length) { + gestures.push(decorate(Gesture.Manual())); + } + + const lastGesture = gestures[gestures.length - 1]!; + + if (onTouchesDown) { + lastGesture.onTouchesDown(onTouchesDown); + } + if (onTouchesUp) { + lastGesture.onTouchesUp(onTouchesUp); + } + } + + return (gestureMode === 'exclusive' + ? Gesture.Exclusive(...gestures) + : Gesture.Simultaneous(...gestures)) as unknown as SortableGesture; + }, [ + failDistance, + onTap, + onDoubleTap, + onLongPress, + onTouchesDown, + onTouchesUp, + externalGesture, + gestureMode + ]); + +export const adapter: GestureHandlerAdapter = { + useDragGesture, + useEnabledGesture, + useTouchableGesture +}; 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 new file mode 100644 index 00000000..58a7ed30 --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/v3.ts @@ -0,0 +1,186 @@ +// `SingleGesture` only exists in gesture-handler v3; this is a type-only import +// (erased at runtime) used to bridge the public `SortableGesture` back to the v3 +// hook gesture type, so it never affects the v2 fallback path. +import type { SingleGesture } from 'react-native-gesture-handler'; +import * as GestureHandler from 'react-native-gesture-handler'; +import type { SharedValue } from 'react-native-reanimated'; + +import { useMutableValue } from '../../reanimated'; +import type { ManualGestureControl, SortableGesture } from '../types'; +import type { GestureHandlerAdapter } from './types'; + +/** + * gesture-handler v3 (hook API) implementation, used when + * react-native-gesture-handler >= 3 is installed. + * + * This path fixes issue #349: unlike the v2 imperative builder, the v3 hook + * gestures keep receiving touches after a screen is detached and re-attached + * (e.g. bottom tabs with `detachInactiveScreens={false}` on iOS + New + * Architecture), so a drag no longer gets "stuck" or stops activating. + */ + +// Resolved lazily at module load; these are `undefined` when an older +// gesture-handler is installed, in which case this adapter is never selected +// (see `../index`). The namespace import keeps bundlers from failing on the +// missing named exports. +const { + GestureStateManager, + useExclusiveGestures, + useLongPressGesture, + useManualGesture, + useSimultaneousGestures, + useTapGesture +} = GestureHandler; + +function createControl( + handlerTag: number, + pendingActivation: SharedValue +): ManualGestureControl { + 'worklet'; + return { + // The library activates the gesture from a delayed timeout (the drag + // activation delay). gesture-handler v3's `_setGestureStateSync` throws when + // called outside a gesture event, so we cannot activate from the timeout + // directly. Instead we flag the request and perform the actual activation in + // the next `onTouchesMove`, which runs inside a gesture event. + activate: () => { + 'worklet'; + pendingActivation.value = true; + }, + end: () => { + 'worklet'; + GestureStateManager.deactivate(handlerTag); + }, + fail: () => { + 'worklet'; + pendingActivation.value = false; + GestureStateManager.fail(handlerTag); + } + }; +} + +const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { + 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) { + pendingActivation.value = false; + GestureStateManager.activate(event.handlerTag); + } + callbacks.onTouchesMove( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesUp: event => { + 'worklet'; + callbacks.onTouchesUp( + event, + createControl(event.handlerTag, pendingActivation) + ); + } + }) as unknown as SortableGesture; +}; + +type ConfigurableGesture = { config?: { enabled?: boolean } }; + +const useEnabledGesture: GestureHandlerAdapter['useEnabledGesture'] = ( + gesture, + enabled +) => { + const configurable = gesture as ConfigurableGesture; + return { + ...configurable, + config: { ...configurable.config, enabled } + } as unknown as SortableGesture; +}; + +const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ + externalGesture, + failDistance, + gestureMode, + onDoubleTap, + onLongPress, + onTap, + onTouchesDown, + onTouchesUp +}) => { + // The external (drag) gesture is tracked with the cross-version + // `SortableGesture` type; cast it back to the v3 gesture type for the + // simultaneous relation. + const external = externalGesture as unknown as SingleGesture; + + // Hooks must run unconditionally, so every gesture is always created and the + // ones without a handler are simply disabled. + const tap = useTapGesture({ + enabled: !!onTap, + maxDistance: failDistance, + onActivate: onTap, + runOnJS: true, + simultaneousWith: external + }); + + const doubleTap = useTapGesture({ + enabled: !!onDoubleTap, + maxDistance: failDistance, + numberOfTaps: 2, + onActivate: onDoubleTap, + runOnJS: true, + simultaneousWith: external + }); + + const longPress = useLongPressGesture({ + enabled: !!onLongPress, + maxDistance: failDistance, + onActivate: onLongPress, + runOnJS: true, + simultaneousWith: external + }); + + const manual = useManualGesture({ + enabled: !!(onTouchesDown ?? onTouchesUp), + onTouchesDown, + onTouchesUp, + runOnJS: true, + simultaneousWith: external + }); + + // Both compositions are created (rules of hooks), but only the one matching + // the requested mode is returned and attached to a detector - the relations + // of a composition are applied when it is attached, so the unused one is + // inert. + const exclusive = useExclusiveGestures(tap, doubleTap, longPress, manual); + const simultaneous = useSimultaneousGestures( + tap, + doubleTap, + longPress, + manual + ); + + return (gestureMode === 'exclusive' + ? exclusive + : simultaneous) as unknown as SortableGesture; +}; + +export const adapter: GestureHandlerAdapter = { + useDragGesture, + useEnabledGesture, + useTouchableGesture +}; diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/detector.tsx b/packages/react-native-sortables/src/integrations/gesture-handler/detector.tsx new file mode 100644 index 00000000..631f4c32 --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/detector.tsx @@ -0,0 +1,26 @@ +import type { PropsWithChildren } from 'react'; +import { GestureDetector as RNGestureDetector } from 'react-native-gesture-handler'; + +import type { SortableGesture } from './types'; + +type GestureDetectorProps = PropsWithChildren<{ + gesture: SortableGesture; + userSelect?: 'auto' | 'none' | 'text'; +}>; + +/** + * Bridges the library's unified {@link SortableGesture} to gesture-handler's + * `GestureDetector`: the two majors type gestures differently, so cast back to + * whatever the installed version's detector expects. + */ +export default function GestureDetector({ + children, + gesture, + userSelect +}: GestureDetectorProps) { + return ( + + {children} + + ); +} diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/index.ts b/packages/react-native-sortables/src/integrations/gesture-handler/index.ts new file mode 100644 index 00000000..b7500efc --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/index.ts @@ -0,0 +1,29 @@ +import * as GestureHandler from 'react-native-gesture-handler'; + +import { adapter as v2Adapter } from './adapters/v2'; +import { adapter as v3Adapter } from './adapters/v3'; + +/** + * The v3 hook API (`useManualGesture`, ...) only exists in + * react-native-gesture-handler >= 3. We pick the matching adapter once, at + * module load - the installed gesture-handler version never changes at runtime, + * so the selected hooks are always called in the same order across renders. + */ +const hasHookApi = + typeof (GestureHandler as { useManualGesture?: unknown }).useManualGesture === + 'function'; + +const adapter = hasHookApi ? v3Adapter : v2Adapter; + +export const useDragGesture = adapter.useDragGesture; +export const useEnabledGesture = adapter.useEnabledGesture; +export const useTouchableGesture = adapter.useTouchableGesture; + +export { default as GestureDetector } from './detector'; +export type { + GestureTouchEvent, + ManualGestureControl, + SortableGesture, + TouchableGestureConfig, + TouchData +} from './types'; diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts new file mode 100644 index 00000000..8d39addc --- /dev/null +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -0,0 +1,62 @@ +import type { + ComposedGesture, + GestureTouchEvent, + GestureType, + TouchData +} from 'react-native-gesture-handler'; + +export type { GestureTouchEvent, TouchData }; + +/** + * A gesture object that can be handed to ``. + * + * The two gesture-handler major versions model gestures differently (the v2 + * imperative builder returns gesture instances, the v3 hook API returns plain + * descriptors). We type this with `GestureType`/`ComposedGesture`, which are the + * only gesture names exported by BOTH majors, so the published types resolve for + * consumers on either version. + */ +export type SortableGesture = ComposedGesture | GestureType; + +/** + * Imperative control over a manual gesture's recognition state. Maps to the v2 + * gesture `manager` argument or to the v3 `GestureStateManager` bound to the + * gesture's handler tag. + */ +export type ManualGestureControl = { + activate: () => void; + end: () => void; + fail: () => void; +}; + +/** Touch lifecycle callbacks for the draggable item's manual gesture. */ +export type ManualGestureCallbacks = { + onTouchesCancelled: ( + event: GestureTouchEvent, + control: ManualGestureControl + ) => void; + onTouchesDown: ( + event: GestureTouchEvent, + control: ManualGestureControl + ) => void; + onTouchesMove: ( + event: GestureTouchEvent, + control: ManualGestureControl + ) => void; + onTouchesUp: ( + event: GestureTouchEvent, + control: ManualGestureControl + ) => void; +}; + +/** Configuration for the composed gesture used by `SortableTouchable`. */ +export type TouchableGestureConfig = { + externalGesture: SortableGesture; + failDistance: number; + gestureMode: 'exclusive' | 'simultaneous'; + onDoubleTap?: () => void; + onLongPress?: () => void; + onTap?: () => void; + onTouchesDown?: () => void; + onTouchesUp?: () => void; +}; 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 b1f67b7f..7aa4c06f 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -1,7 +1,6 @@ -import { useMemo } from 'react'; -import { Gesture } from 'react-native-gesture-handler'; import type { SharedValue } from 'react-native-reanimated'; +import { useDragGesture } from '../../../integrations/gesture-handler'; import { useDragContext } from '../DragProvider'; export default function useItemPanGesture( @@ -11,29 +10,33 @@ export default function useItemPanGesture( const { handleDragEnd, handleTouchesMove, handleTouchStart } = useDragContext(); - return useMemo( - () => - Gesture.Manual() - .onTouchesDown((e, manager) => { - handleTouchStart( - e, - key, - activationAnimationProgress, - manager.activate, - manager.fail - ); - }) - .onTouchesMove((e, manager) => { - handleTouchesMove(e, manager.fail); - }) - .onTouchesCancelled((_, manager) => { - handleDragEnd(key, activationAnimationProgress); - manager.fail(); - }) - .onTouchesUp((_, manager) => { - handleDragEnd(key, activationAnimationProgress); - manager.end(); - }), + return useDragGesture( + { + onTouchesCancelled: (_event, control) => { + 'worklet'; + handleDragEnd(key, activationAnimationProgress); + control.fail(); + }, + onTouchesDown: (event, control) => { + 'worklet'; + handleTouchStart( + event, + key, + activationAnimationProgress, + control.activate, + control.fail + ); + }, + onTouchesMove: (event, control) => { + 'worklet'; + handleTouchesMove(event, control.fail); + }, + onTouchesUp: (_event, control) => { + 'worklet'; + handleDragEnd(key, activationAnimationProgress); + control.end(); + } + }, [ handleDragEnd, handleTouchStart, diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 4121405d..3097ce25 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -1,9 +1,6 @@ import type { ReactNode } from 'react'; import type { ScrollView, View } from 'react-native'; -import type { - GestureTouchEvent, - GestureType -} from 'react-native-gesture-handler'; +import type { GestureTouchEvent } from 'react-native-gesture-handler'; import type { AnimatedRef, MeasuredDimensions, @@ -11,6 +8,7 @@ import type { } from 'react-native-reanimated'; import type { DeepReadonly, Maybe, Simplify } from '../../helperTypes'; +import type { SortableGesture } from '../../integrations/gesture-handler'; import type { AnimatedValues } from '../../integrations/reanimated'; import type { DebugCrossUpdater, @@ -157,7 +155,7 @@ export type ItemContextType = Simplify< activationAnimationProgress: SharedValue; } > & { - gesture: GestureType; + gesture: SortableGesture; } >; diff --git a/yarn.lock b/yarn.lock index 6468fb60..d4d5baf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14919,6 +14919,19 @@ __metadata: languageName: node linkType: hard +"react-native-gesture-handler@npm:3.0.2": + version: 3.0.2 + resolution: "react-native-gesture-handler@npm:3.0.2" + dependencies: + "@types/react-test-renderer": "npm:^19.1.0" + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/06d2b1a621f73c148c4000692e55d5f2a44d44229cabc79829de0b0b3dbf3bcf4bc00e8635f82bd1062d7eac87f39ca5213eb1041e23a5543947e64773eaddfb + languageName: node + linkType: hard + "react-native-gesture-handler@npm:~2.31.1": version: 2.31.2 resolution: "react-native-gesture-handler@npm:2.31.2" @@ -15126,7 +15139,7 @@ __metadata: react: "npm:19.2.3" react-native: "npm:0.85.3" react-native-builder-bob: "npm:0.40.13" - react-native-gesture-handler: "npm:2.31.1" + react-native-gesture-handler: "npm:3.0.2" react-native-haptic-feedback: "npm:>=2.0.0" react-native-reanimated: "npm:4.5.0" react-native-worklets: "npm:0.10.0" From dbdf586e689e7d644e206c38d9b39429f0c08060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 27 Jun 2026 23:11:58 +0200 Subject: [PATCH 2/6] Simplify gesture-handler adapter casts and comments Remove the as-unknown-as double casts (centralize gesture bridging in a single asSortableGesture helper plus narrowing casts) and trim the adapter comments to the non-obvious rationale only. No behavior change. --- .../gesture-handler/adapters/v2.ts | 87 +++++------ .../gesture-handler/adapters/v3.ts | 141 ++++++++---------- .../src/integrations/gesture-handler/index.ts | 9 +- .../src/integrations/gesture-handler/types.ts | 17 +-- 4 files changed, 106 insertions(+), 148 deletions(-) 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 9915c259..0fb59982 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 @@ -2,51 +2,38 @@ import { useMemo } from 'react'; import type { GestureType } from 'react-native-gesture-handler'; import { Gesture } from 'react-native-gesture-handler'; -import type { SortableGesture } from '../types'; +import { asSortableGesture } from '../types'; import type { GestureHandlerAdapter } from './types'; /** - * gesture-handler v2 (imperative builder) implementation. This is the legacy - * path used when react-native-gesture-handler < 3 is installed (e.g. on the Old - * Architecture / Paper, which gesture-handler v3 no longer supports). - * - * NOTE: on iOS + New Architecture this path is affected by the upstream - * gesture-handler limitation described in issue #349 (gestures stop being - * recognized after a screen is detached and re-attached, e.g. bottom tabs with - * `detachInactiveScreens={false}`). The limitation is not fixable here and is - * resolved only by the v3 hook API - see `./v3`. + * gesture-handler v2 imperative builder, used when gesture-handler < 3 is + * installed (e.g. the Old Architecture / Paper). On iOS + New Architecture this + * path still has the upstream issue #349 limitation, which only the v3 hook API + * fixes (see `./v3`). */ const useDragGesture: GestureHandlerAdapter['useDragGesture'] = ( callbacks, deps ) => - useMemo( - () => - Gesture.Manual() - .onTouchesDown((event, manager) => - callbacks.onTouchesDown(event, manager) - ) - .onTouchesMove((event, manager) => - callbacks.onTouchesMove(event, manager) - ) - .onTouchesCancelled((event, manager) => - callbacks.onTouchesCancelled(event, manager) - ) - .onTouchesUp((event, manager) => callbacks.onTouchesUp(event, manager)), - // The caller (useItemPanGesture) controls the dependency list; `callbacks` - // is rebuilt whenever any of these change. - // eslint-disable-next-line react-hooks/exhaustive-deps - deps - ) as unknown as SortableGesture; + asSortableGesture( + useMemo( + () => + Gesture.Manual() + .onTouchesDown(callbacks.onTouchesDown) + .onTouchesMove(callbacks.onTouchesMove) + .onTouchesCancelled(callbacks.onTouchesCancelled) + .onTouchesUp(callbacks.onTouchesUp), + // The dependency list is owned by the caller (useItemPanGesture). + // eslint-disable-next-line react-hooks/exhaustive-deps + deps + ) + ); const useEnabledGesture: GestureHandlerAdapter['useEnabledGesture'] = ( gesture, enabled -) => - (gesture as unknown as GestureType).enabled( - enabled - ) as unknown as SortableGesture; +) => asSortableGesture((gesture as GestureType).enabled(enabled)); const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ externalGesture, @@ -59,18 +46,16 @@ const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ onTouchesUp }) => useMemo(() => { - const decorate = (decoratedGesture: T): T => { - decoratedGesture - .simultaneousWithExternalGesture( - externalGesture as unknown as GestureType - ) + const decorate = (gesture: T): T => { + gesture + .simultaneousWithExternalGesture(externalGesture as GestureType) .runOnJS(true); - if ('maxDistance' in decoratedGesture) { + if ('maxDistance' in gesture) { ( - decoratedGesture as { maxDistance: (distance: number) => GestureType } + gesture as { maxDistance: (distance: number) => GestureType } ).maxDistance(failDistance); } - return decoratedGesture; + return gesture; }; const gestures: Array = []; @@ -78,37 +63,33 @@ const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ if (onTap) { gestures.push(decorate(Gesture.Tap()).onStart(onTap)); } - if (onDoubleTap) { gestures.push( decorate(Gesture.Tap()).numberOfTaps(2).onStart(onDoubleTap) ); } - if (onLongPress) { gestures.push(decorate(Gesture.LongPress()).onStart(onLongPress)); } if (onTouchesDown || onTouchesUp) { - // Reuse the already added gesture if possible or create a manual gesture - // if there is no other gesture yet + const target = gestures.at(-1) ?? decorate(Gesture.Manual()); if (!gestures.length) { - gestures.push(decorate(Gesture.Manual())); + gestures.push(target); } - - const lastGesture = gestures[gestures.length - 1]!; - if (onTouchesDown) { - lastGesture.onTouchesDown(onTouchesDown); + target.onTouchesDown(onTouchesDown); } if (onTouchesUp) { - lastGesture.onTouchesUp(onTouchesUp); + target.onTouchesUp(onTouchesUp); } } - return (gestureMode === 'exclusive' - ? Gesture.Exclusive(...gestures) - : Gesture.Simultaneous(...gestures)) as unknown as SortableGesture; + return asSortableGesture( + gestureMode === 'exclusive' + ? Gesture.Exclusive(...gestures) + : Gesture.Simultaneous(...gestures) + ); }, [ failDistance, onTap, 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 58a7ed30..2f0bb6e8 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,28 +1,21 @@ -// `SingleGesture` only exists in gesture-handler v3; this is a type-only import -// (erased at runtime) used to bridge the public `SortableGesture` back to the v3 -// hook gesture type, so it never affects the v2 fallback path. import type { SingleGesture } from 'react-native-gesture-handler'; import * as GestureHandler from 'react-native-gesture-handler'; import type { SharedValue } from 'react-native-reanimated'; import { useMutableValue } from '../../reanimated'; -import type { ManualGestureControl, SortableGesture } from '../types'; +import type { ManualGestureControl } from '../types'; +import { asSortableGesture } from '../types'; import type { GestureHandlerAdapter } from './types'; /** - * gesture-handler v3 (hook API) implementation, used when - * react-native-gesture-handler >= 3 is installed. + * gesture-handler v3 hook API, used when gesture-handler >= 3 is installed. This + * is what fixes issue #349: v3 hook gestures keep receiving touches after a + * screen is detached and re-attached, so a drag no longer gets stuck. * - * This path fixes issue #349: unlike the v2 imperative builder, the v3 hook - * gestures keep receiving touches after a screen is detached and re-attached - * (e.g. bottom tabs with `detachInactiveScreens={false}` on iOS + New - * Architecture), so a drag no longer gets "stuck" or stops activating. + * The hooks are resolved through a namespace import so bundlers don't fail on + * these (v3-only) names when an older gesture-handler is installed - this + * adapter is only ever selected when they exist (see `../index`). */ - -// Resolved lazily at module load; these are `undefined` when an older -// gesture-handler is installed, in which case this adapter is never selected -// (see `../index`). The namespace import keeps bundlers from failing on the -// missing named exports. const { GestureStateManager, useExclusiveGestures, @@ -38,11 +31,9 @@ function createControl( ): ManualGestureControl { 'worklet'; return { - // The library activates the gesture from a delayed timeout (the drag - // activation delay). gesture-handler v3's `_setGestureStateSync` throws when - // called outside a gesture event, so we cannot activate from the timeout - // directly. Instead we flag the request and perform the actual activation in - // the next `onTouchesMove`, which runs inside a gesture event. + // `GestureStateManager.activate` throws when called outside a gesture event, + // but the library activates from a delayed timeout - so flag it here and run + // the real activation in the next in-event `onTouchesMove`. activate: () => { 'worklet'; pendingActivation.value = true; @@ -62,54 +53,54 @@ function createControl( const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { 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) { + return asSortableGesture( + useManualGesture({ + onTouchesCancel: event => { + 'worklet'; + callbacks.onTouchesCancelled( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesDown: event => { + 'worklet'; pendingActivation.value = false; - GestureStateManager.activate(event.handlerTag); + callbacks.onTouchesDown( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesMove: event => { + 'worklet'; + if (pendingActivation.value) { + pendingActivation.value = false; + GestureStateManager.activate(event.handlerTag); + } + callbacks.onTouchesMove( + event, + createControl(event.handlerTag, pendingActivation) + ); + }, + onTouchesUp: event => { + '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) - ); - } - }) as unknown as SortableGesture; + }) + ); }; -type ConfigurableGesture = { config?: { enabled?: boolean } }; - const useEnabledGesture: GestureHandlerAdapter['useEnabledGesture'] = ( gesture, enabled ) => { - const configurable = gesture as ConfigurableGesture; - return { - ...configurable, - config: { ...configurable.config, enabled } - } as unknown as SortableGesture; + const current = gesture as { config?: object }; + return asSortableGesture({ + ...current, + config: { ...current.config, enabled } + }); }; const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ @@ -122,50 +113,40 @@ const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ onTouchesDown, onTouchesUp }) => { - // The external (drag) gesture is tracked with the cross-version - // `SortableGesture` type; cast it back to the v3 gesture type for the - // simultaneous relation. - const external = externalGesture as unknown as SingleGesture; + const simultaneousWith = externalGesture as object as SingleGesture; - // Hooks must run unconditionally, so every gesture is always created and the - // ones without a handler are simply disabled. + // Hooks run unconditionally, so every gesture is always created; the ones + // without a handler stay disabled. const tap = useTapGesture({ enabled: !!onTap, maxDistance: failDistance, onActivate: onTap, runOnJS: true, - simultaneousWith: external + simultaneousWith }); - const doubleTap = useTapGesture({ enabled: !!onDoubleTap, maxDistance: failDistance, numberOfTaps: 2, onActivate: onDoubleTap, runOnJS: true, - simultaneousWith: external + simultaneousWith }); - const longPress = useLongPressGesture({ enabled: !!onLongPress, maxDistance: failDistance, onActivate: onLongPress, runOnJS: true, - simultaneousWith: external + simultaneousWith }); - const manual = useManualGesture({ enabled: !!(onTouchesDown ?? onTouchesUp), onTouchesDown, onTouchesUp, runOnJS: true, - simultaneousWith: external + simultaneousWith }); - // Both compositions are created (rules of hooks), but only the one matching - // the requested mode is returned and attached to a detector - the relations - // of a composition are applied when it is attached, so the unused one is - // inert. const exclusive = useExclusiveGestures(tap, doubleTap, longPress, manual); const simultaneous = useSimultaneousGestures( tap, @@ -174,9 +155,9 @@ const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({ manual ); - return (gestureMode === 'exclusive' - ? exclusive - : simultaneous) as unknown as SortableGesture; + return asSortableGesture( + gestureMode === 'exclusive' ? exclusive : simultaneous + ); }; export const adapter: GestureHandlerAdapter = { 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 b7500efc..a7552d1c 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/index.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/index.ts @@ -3,12 +3,9 @@ import * as GestureHandler from 'react-native-gesture-handler'; import { adapter as v2Adapter } from './adapters/v2'; import { adapter as v3Adapter } from './adapters/v3'; -/** - * The v3 hook API (`useManualGesture`, ...) only exists in - * react-native-gesture-handler >= 3. We pick the matching adapter once, at - * module load - the installed gesture-handler version never changes at runtime, - * so the selected hooks are always called in the same order across renders. - */ +// The v3 hook API only exists in gesture-handler >= 3. Pick the adapter once at +// module load (the installed version is fixed at runtime) so the chosen hooks are +// called in a stable order across renders. const hasHookApi = typeof (GestureHandler as { useManualGesture?: unknown }).useManualGesture === 'function'; 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 8d39addc..3a2e82a2 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -8,20 +8,19 @@ import type { export type { GestureTouchEvent, TouchData }; /** - * A gesture object that can be handed to ``. - * - * The two gesture-handler major versions model gestures differently (the v2 - * imperative builder returns gesture instances, the v3 hook API returns plain - * descriptors). We type this with `GestureType`/`ComposedGesture`, which are the - * only gesture names exported by BOTH majors, so the published types resolve for + * A gesture handed to ``. Typed with the only gesture names + * exported by both gesture-handler majors, so published types resolve for * consumers on either version. */ export type SortableGesture = ComposedGesture | GestureType; +/** Re-tags a version-specific gesture object as the unified {@link SortableGesture}. */ +export const asSortableGesture = (gesture: object): SortableGesture => + gesture as SortableGesture; + /** - * Imperative control over a manual gesture's recognition state. Maps to the v2 - * gesture `manager` argument or to the v3 `GestureStateManager` bound to the - * gesture's handler tag. + * Imperative control over a manual gesture's recognition state - the v2 gesture + * `manager`, or the v3 `GestureStateManager` bound to the handler tag. */ export type ManualGestureControl = { activate: () => void; From 015a4e06b759211e64ff870ed88be43fed53a7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sun, 28 Jun 2026 11:13:16 +0200 Subject: [PATCH 3/6] Pin gesture-handler to a single mocked instance in example app tests react-native-sortables is built against gesture-handler v3, so importing it in the example app's jest tests pulled in an unmocked v3 native module (RNGestureHandlerModule getEnforcing). Map every gesture-handler import to the single mocked instance the app already sets up. --- example/app/jest.config.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/example/app/jest.config.ts b/example/app/jest.config.ts index 8c7ab586..ada82f1a 100644 --- a/example/app/jest.config.ts +++ b/example/app/jest.config.ts @@ -9,9 +9,15 @@ const config: JestConfigWithTsJest = { }, moduleDirectories: ['node_modules', '../../node_modules', ''], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths ?? {}, { - prefix: '/' - }), + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths ?? {}, { + prefix: '/' + }), + // react-native-sortables is built against gesture-handler v3, but the app's + // jest mock targets v2; pin every import to the single mocked instance. + '^react-native-gesture-handler$': + '/../../node_modules/react-native-gesture-handler' + }, preset: '@react-native/jest-preset', resolver: 'react-native-worklets/jest/resolver.js', setupFilesAfterEnv: ['/jest.setup.js'], From 2e0e45be5e30cb63ca81b59007f99537b34559b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sun, 28 Jun 2026 12:35:44 +0200 Subject: [PATCH 4/6] Replace GestureDetector as-never casts with a typed component alias --- .../shared/SortableGestureDetector.tsx | 13 ++++++++----- .../shared/SortableGestureDetector.web.tsx | 19 ++++++++++++------- .../gesture-handler/adapters/v3.ts | 2 ++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx index 4f910f57..78c57355 100644 --- a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx @@ -9,6 +9,13 @@ export type SortableGestureDetectorProps = PropsWithChildren<{ gesture: ComposedGesture | GestureType; }>; +// The exported `GestureDetector` is generic and infers its gesture prop to the +// v3-only gesture type; pin it to the legacy props shape that accepts the +// cross-major `SortableGesture` union. +const Detector = GestureDetector as ( + props: SortableGestureDetectorProps +) => ReturnType; + /** * Wrapper over gesture handler's `GestureDetector` used by all draggable item * parts. On native it is a passthrough; the web counterpart (`.web`) layers on @@ -18,9 +25,5 @@ export default function SortableGestureDetector({ children, gesture }: SortableGestureDetectorProps) { - // `gesture` spans both gesture-handler majors; cast to the installed - // detector's prop type. - return ( - {children} - ); + return {children}; } diff --git a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx index 40f66b1f..65f5fc4a 100644 --- a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx @@ -29,6 +29,16 @@ export type SortableGestureDetectorProps = PropsWithChildren<{ gesture: ComposedGesture | GestureType; }>; +// The exported `GestureDetector` is generic and infers its gesture prop to the +// v3-only gesture type; pin it to the legacy props shape that accepts the +// cross-major `SortableGesture` union, plus the web-only layout props. +const Detector = GestureDetector as ( + props: SortableGestureDetectorProps & { + touchAction?: 'pan-x' | 'pan-y'; + userSelect?: 'none'; + } +) => ReturnType; + /** * Web `GestureDetector`: relaxes `touch-action` to the scroll axis (so items * don't block scrolling the ScrollView) and blocks native scroll while dragging. @@ -67,13 +77,8 @@ export default function SortableGestureDetector({ useEffect(() => () => setBlocking(false), [setBlocking]); return ( - // `gesture` spans both gesture-handler majors; cast to the installed - // detector's prop type. - + {children} - + ); } 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 2f0bb6e8..2ea84e99 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 @@ -50,6 +50,8 @@ function createControl( }; } +// Unlike the v2 builder, v3 hooks re-apply their config (and worklet callbacks) +// on every render, so the caller's `deps` list is not needed here. const useDragGesture: GestureHandlerAdapter['useDragGesture'] = callbacks => { const pendingActivation = useMutableValue(false); From 2637cca9e9cd3e16429bba2b84731a0c950a44e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sun, 28 Jun 2026 12:46:21 +0200 Subject: [PATCH 5/6] Tighten GestureDetector cast comments --- .../src/components/shared/SortableGestureDetector.tsx | 5 ++--- .../src/components/shared/SortableGestureDetector.web.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx index 78c57355..21ff52fb 100644 --- a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.tsx @@ -9,9 +9,8 @@ export type SortableGestureDetectorProps = PropsWithChildren<{ gesture: ComposedGesture | GestureType; }>; -// The exported `GestureDetector` is generic and infers its gesture prop to the -// v3-only gesture type; pin it to the legacy props shape that accepts the -// cross-major `SortableGesture` union. +// Cast `GestureDetector` to its legacy props so it accepts the cross-major +// `SortableGesture` union (its exported types otherwise reject it). const Detector = GestureDetector as ( props: SortableGestureDetectorProps ) => ReturnType; diff --git a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx index 65f5fc4a..cc002349 100644 --- a/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableGestureDetector.web.tsx @@ -29,9 +29,8 @@ export type SortableGestureDetectorProps = PropsWithChildren<{ gesture: ComposedGesture | GestureType; }>; -// The exported `GestureDetector` is generic and infers its gesture prop to the -// v3-only gesture type; pin it to the legacy props shape that accepts the -// cross-major `SortableGesture` union, plus the web-only layout props. +// Cast `GestureDetector` to its legacy props (plus web layout props) so it +// accepts the cross-major `SortableGesture` union. const Detector = GestureDetector as ( props: SortableGestureDetectorProps & { touchAction?: 'pan-x' | 'pan-y'; From 8f98b5efd887d67e9e13e52d5400370327a8a7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sun, 28 Jun 2026 23:41:22 +0200 Subject: [PATCH 6/6] Drop redundant JSDoc from gesture-handler adapter types --- .../src/integrations/gesture-handler/adapters/types.ts | 3 --- .../src/integrations/gesture-handler/types.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts index a9b4447f..806655fd 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/adapters/types.ts @@ -10,16 +10,13 @@ import type { * selected once at module load based on which version is installed. */ export type GestureHandlerAdapter = { - /** Manual pan gesture for a draggable item. */ useDragGesture: ( callbacks: ManualGestureCallbacks, deps: ReadonlyArray ) => SortableGesture; - /** Applies an `enabled` flag to an existing gesture (custom handle). */ useEnabledGesture: ( gesture: SortableGesture, enabled: boolean ) => SortableGesture; - /** Composed tap / long-press / manual gesture for `SortableTouchable`. */ useTouchableGesture: (config: TouchableGestureConfig) => SortableGesture; }; 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 3a2e82a2..a7f9aa3d 100644 --- a/packages/react-native-sortables/src/integrations/gesture-handler/types.ts +++ b/packages/react-native-sortables/src/integrations/gesture-handler/types.ts @@ -14,7 +14,6 @@ export type { GestureTouchEvent, TouchData }; */ export type SortableGesture = ComposedGesture | GestureType; -/** Re-tags a version-specific gesture object as the unified {@link SortableGesture}. */ export const asSortableGesture = (gesture: object): SortableGesture => gesture as SortableGesture; @@ -28,7 +27,6 @@ export type ManualGestureControl = { fail: () => void; }; -/** Touch lifecycle callbacks for the draggable item's manual gesture. */ export type ManualGestureCallbacks = { onTouchesCancelled: ( event: GestureTouchEvent, @@ -48,7 +46,6 @@ export type ManualGestureCallbacks = { ) => void; }; -/** Configuration for the composed gesture used by `SortableTouchable`. */ export type TouchableGestureConfig = { externalGesture: SortableGesture; failDistance: number;