From 7a0408fe3217217e9f6634ebfe4f1cd29b7031b3 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 8 Oct 2026 14:43:54 +0200 Subject: [PATCH 1/8] Migrate attachment carousel and MultiGestureCanvas gestures to Gesture Handler 3 hooks Move the attachment carousel (web pan and native pager), the Android PDF pager pan and MultiGestureCanvas (pinch, pan, single and double tap) from the deprecated builder API to the hook API. Gesture refs don't exist in the hook API, so the web carousel passes its pan gesture through the pager context as pagerGesture instead of the gesture ref in pagerRef. pagerRef now points to the pager itself and only tells the attachment it is rendered inside a carousel. In MultiGestureCanvas the pan gesture is created before the taps, because the single tap waits for it to fail; the taps declare the simultaneous relation with the pan instead, which is symmetric. Without the refs read during render, AttachmentCarouselView and MultiGestureCanvas now compile with React Compiler, so the React.memo workaround is dropped and the seatbelt baseline is tightened. --- config/eslint/eslint.seatbelt.tsv | 23 +---- .../AttachmentCarouselView/index.tsx | 96 +++++++++---------- .../AttachmentCarousel/Pager/index.tsx | 4 +- .../AttachmentCarousel/Pager/types.ts | 15 ++- .../AttachmentViewPdf/index.android.tsx | 15 +-- .../Attachments/MultiGestureIcon/index.tsx | 4 +- .../MultiGestureIcon/useIconCarouselPager.ts | 6 +- src/components/Lightbox/index.tsx | 5 +- src/components/MultiGestureCanvas/index.tsx | 75 +++++++-------- .../MultiGestureCanvas/usePanGesture.ts | 56 +++++++---- .../MultiGestureCanvas/usePinchGesture.ts | 52 ++++++---- .../MultiGestureCanvas/useTapGestures.ts | 54 ++++++----- src/components/PDFView/index.tsx | 2 +- 13 files changed, 211 insertions(+), 196 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 333f2618b57e..f922acccb4ff 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -66,17 +66,10 @@ "../../src/components/AttachmentPicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPicker/index.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 3 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/LegacyComposedGesture" 1 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "react-hooks/refs" 3 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Native" 2 "../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/Attachments/AttachmentCarousel/Pager/types.ts" "@typescript-eslint/no-deprecated/GestureType" 2 "../../src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts" "no-restricted-syntax" 1 -"../../src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/Attachments/AttachmentView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 @@ -249,23 +242,9 @@ "../../src/components/MultifactorAuthentication/Context/stateReducer.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/Context/usePromptContent.ts" "react-hooks/refs" 10 "../../src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx" "no-restricted-syntax" 1 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Simultaneous" 2 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureRef" 1 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 -"../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 -"../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/refs" 2 -"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 -"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pinch" 2 -"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/LegacyPinchGesture" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 -"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 4 -"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/LegacyTapGesture" 2 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index c207987a8ad7..ed902549d996 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -21,13 +21,12 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {RefObject} from 'react'; import type {ListRenderItemInfo} from 'react-native'; -import type {LegacyComposedGesture, GestureType} from 'react-native-gesture-handler'; +import type {PanGesture} from 'react-native-gesture-handler'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Keyboard, PixelRatio, View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type AttachmentCarouselViewProps from './types'; @@ -42,7 +41,7 @@ const MIN_FLING_VELOCITY = 500; type DeviceAwareGestureDetectorProps = { canUseTouchScreen: boolean; - gesture: LegacyComposedGesture | GestureType; + gesture: PanGesture; children: React.ReactNode; }; @@ -78,7 +77,6 @@ function AttachmentCarouselView({ const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); - const pagerRef = useRef(null); const scrollRef = useAnimatedRef>>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -157,15 +155,53 @@ function AttachmentCarouselView({ [cellWidth], ); + /** Pan gesture handing swiping through attachments on touch screen devices */ + const pan = usePanGesture({ + enabled: canUseTouchScreen, + onUpdate: ({translationX}) => { + if (!isScrollEnabled.get()) { + return; + } + + if (translationX !== 0) { + isPagerScrolling.set(true); + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }, + onDeactivate: ({translationX, velocityX}) => { + if (!isScrollEnabled.get()) { + return; + } + + let newIndex; + if (velocityX > MIN_FLING_VELOCITY) { + // User flung to the right + newIndex = Math.max(0, page - 1); + } else if (velocityX < -MIN_FLING_VELOCITY) { + // User flung to the left + newIndex = Math.min(attachments.length - 1, page + 1); + } else { + // snap scroll position to the nearest cell (making sure it's within the bounds of the list) + const delta = Math.round(-translationX / cellWidth); + newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); + } + + isPagerScrolling.set(false); + scrollTo(scrollRef, newIndex * cellWidth, 0, true); + }, + }); + const stateValue = useMemo( () => ({ pagerItems: [{source, index: 0, isActive: true}], activePage: 0, - pagerRef, + pagerRef: scrollRef, + pagerGesture: pan, isPagerScrolling, isScrollEnabled, }), - [source, isPagerScrolling, isScrollEnabled], + [source, scrollRef, pan, isPagerScrolling, isScrollEnabled], ); const actionsValue = useMemo( @@ -193,46 +229,6 @@ function AttachmentCarouselView({ ), [activeAttachmentID, canUseTouchScreen, cellWidth, handleTap, report?.reportID, shouldShowArrows, styles.h100], ); - /** Pan gesture handing swiping through attachments on touch screen devices */ - const pan = useMemo( - () => - Gesture.Pan() - .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => { - if (!isScrollEnabled.get()) { - return; - } - - if (translationX !== 0) { - isPagerScrolling.set(true); - } - - scrollTo(scrollRef, page * cellWidth - translationX, 0, false); - }) - .onEnd(({translationX, velocityX}) => { - if (!isScrollEnabled.get()) { - return; - } - - let newIndex; - if (velocityX > MIN_FLING_VELOCITY) { - // User flung to the right - newIndex = Math.max(0, page - 1); - } else if (velocityX < -MIN_FLING_VELOCITY) { - // User flung to the left - newIndex = Math.min(attachments.length - 1, page + 1); - } else { - // snap scroll position to the nearest cell (making sure it's within the bounds of the list) - const delta = Math.round(-translationX / cellWidth); - newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); - } - - isPagerScrolling.set(false); - scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }) - .withRef(pagerRef as RefObject), - [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], - ); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { @@ -303,6 +299,4 @@ function AttachmentCarouselView({ ); } -// OXC's React Compiler bails on this file (refs accessed during render in gesture handlers), so it -// is not memoized on web. Memoize it explicitly to keep parent-driven re-renders cheap there. -export default React.memo(AttachmentCarouselView); +export default AttachmentCarouselView; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index f1f9a8545baa..f7dbe5043d2f 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -9,7 +9,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, useNativeGesture} from 'react-native-gesture-handler'; import PagerView from 'react-native-pager-view'; import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; @@ -85,7 +85,7 @@ function AttachmentCarouselPager({items, activeAttachmentID, initialPage, setSho const extractItemKey = useCallback((item: Attachment, index: number) => `attachmentID-${item.attachmentID}-${index}`, []); - const nativeGestureHandler = Gesture.Native(); + const nativeGestureHandler = useNativeGesture(); const stateValue = useMemo( () => ({ diff --git a/src/components/Attachments/AttachmentCarousel/Pager/types.ts b/src/components/Attachments/AttachmentCarousel/Pager/types.ts index 8d84beb7a1ad..0d33a364a958 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/types.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/types.ts @@ -1,9 +1,11 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type {ForwardedRef} from 'react'; -import type {GestureType} from 'react-native-gesture-handler'; +import type {ListRenderItemInfo} from 'react-native'; +import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; -import type {SharedValue} from 'react-native-reanimated'; +import type Animated from 'react-native-reanimated'; +import type {AnimatedRef, SharedValue} from 'react-native-reanimated'; /** The pager items array is used within the pager to render and navigate between the images */ type AttachmentCarouselPagerItems = Pick & { @@ -27,8 +29,11 @@ type AttachmentCarouselPagerStateContextType = { /** Index of the currently active page */ activePage: number; - /** Ref to the active attachment */ - pagerRef?: ForwardedRef; + /** Ref to the pager: the PagerView on native and the attachment list on web */ + pagerRef?: ForwardedRef | AnimatedRef>>; + + /** The pan gesture that swipes between attachments on web. The pan gesture of the attachment needs to work simultaneously with it */ + pagerGesture?: PanGesture; /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; @@ -37,7 +42,7 @@ type AttachmentCarouselPagerStateContextType = { isScrollEnabled: SharedValue; /** In case we need a gesture that should work simultaneously with panning in MultiGestureCanvas */ - externalGestureHandler?: GestureType; + externalGestureHandler?: NativeGesture; }; type AttachmentCarouselPagerActionsContextType = { diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx index 1c1273639647..254d58919b76 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React, {memo, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useSharedValue} from 'react-native-reanimated'; import type AttachmentViewPdfProps from './types'; @@ -33,9 +33,9 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { const offsetY = useSharedValue(0); const isPanGestureActive = useSharedValue(false); - const Pan = Gesture.Pan() - .manualActivation(true) - .onTouchesMove((evt) => { + const Pan = usePanGesture({ + manualActivation: true, + onTouchesMove: (evt) => { if (offsetX.get() !== 0 && offsetY.get() !== 0 && isScrollEnabled && scale.get() === 1) { const translateX = Math.abs((evt.allTouches.at(0)?.absoluteX ?? 0) - offsetX.get()); const translateY = Math.abs((evt.allTouches.at(0)?.absoluteY ?? 0) - offsetY.get()); @@ -54,14 +54,15 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { isPanGestureActive.set(true); offsetX.set(evt.allTouches.at(0)?.absoluteX ?? 0); offsetY.set(evt.allTouches.at(0)?.absoluteY ?? 0); - }) - .onTouchesUp(() => { + }, + onTouchesUp: () => { isPanGestureActive.set(false); if (!isScrollEnabled) { return; } isScrollEnabled.set(scale.get() === 1); - }); + }, + }); const Content = useMemo( () => ( diff --git a/src/components/Attachments/MultiGestureIcon/index.tsx b/src/components/Attachments/MultiGestureIcon/index.tsx index a0488dce25ba..6694ea3cfbbe 100644 --- a/src/components/Attachments/MultiGestureIcon/index.tsx +++ b/src/components/Attachments/MultiGestureIcon/index.tsx @@ -29,7 +29,7 @@ type MultiGestureIconProps = { /** Renders an icon inside a multi-gesture canvas for pinch, pan, and swipe interactions. */ function MultiGestureIcon({additionalStyles, src, contentSize, fill}: MultiGestureIconProps) { const {canvasSize, updateCanvasSize, isCanvasLoading} = useCanvasSize(); - const {pagerRef, isScrollEnabled, onTap, onSwipeDown} = useIconCarouselPager(); + const {pagerGesture, isScrollEnabled, onTap, onSwipeDown} = useIconCarouselPager(); return ( ; @@ -26,7 +26,7 @@ function useIconCarouselPager(): IconCarouselPagerProps { if (state === null || actions === null) { return { - pagerRef: undefined, + pagerGesture: undefined, isScrollEnabled: isScrollingEnabledFallback, onTap: () => {}, onSwipeDown: () => {}, @@ -34,7 +34,7 @@ function useIconCarouselPager(): IconCarouselPagerProps { } return { - pagerRef: state.pagerRef, + pagerGesture: state.pagerGesture, isScrollEnabled: state.isScrollEnabled, onTap: actions.onTap ?? (() => {}), onSwipeDown: actions.onSwipeDown ?? (() => {}), diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index a4df05548a56..30dc04b6e068 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -90,6 +90,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange onScaleChanged: () => {}, onSwipeDown: () => {}, pagerRef: undefined, + pagerGesture: undefined, externalGestureHandler: undefined, }; } else { @@ -112,7 +113,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange onTap, onScaleChanged: onScaleChangedContext, onSwipeDown, - pagerRef, + pagerGesture, isScrollEnabled, externalGestureHandler, } = carouselContext; @@ -213,7 +214,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange canvasSize={canvasSize} contentSize={contentSize} zoomRange={zoomRange} - pagerRef={pagerRef} + pagerGesture={pagerGesture} isUsedInCarousel={isUsedInCarousel} shouldDisableTransformationGestures={isPagerScrolling} isPagerScrollEnabled={isScrollEnabled} diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 4719c65cb6ca..195e5ccb9a3a 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -4,15 +4,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {ForwardedRef} from 'react'; -import type {GestureType} from 'react-native-gesture-handler'; -import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; -import type PagerView from 'react-native-pager-view'; +import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, useCompetingGestures, useSimultaneousGestures} from 'react-native-gesture-handler'; import Animated, {cancelAnimation, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; @@ -52,8 +49,8 @@ type MultiGestureCanvasProps = ChildrenProps & { isPagerScrollEnabled: SharedValue; - /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + /** If there is a pager wrapping the canvas that swipes with a pan gesture (web), the canvas pan gesture needs to work simultaneously with it */ + pagerGesture?: PanGesture; isUsedInCarousel: boolean; @@ -73,7 +70,7 @@ type MultiGestureCanvasProps = ChildrenProps & { shouldPreventTouchEndDefault?: boolean; /** We need to ensure that any native gesture handlers in this component tree is working simultaneously with panning and do not get blocked. */ - externalGestureHandler?: GestureType; + externalGestureHandler?: NativeGesture; }; const defaultContentSize = {width: 1, height: 1}; @@ -84,7 +81,7 @@ function MultiGestureCanvas({ zoomRange: zoomRangeProp, isActive = true, children, - pagerRef, + pagerGesture, isUsedInCarousel, shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp, isTransformGestureActive: isTransformGestureActiveProp, @@ -128,7 +125,6 @@ function MultiGestureCanvas({ const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); const isSwipingDownToClose = useSharedValue(false); - const panGestureRef = useRef(Gesture.Pan()); const pinchScale = useSharedValue(1); const pinchTranslateX = useSharedValue(0); @@ -197,7 +193,29 @@ function MultiGestureCanvas({ [offsetX, offsetY, panTranslateX, panTranslateY, pinchScale, pinchTranslateX, pinchTranslateY, stopAnimation, zoomScale], ); - const {singleTapGesture: baseSingleTapGesture, doubleTapGesture} = useTapGestures({ + // Gestures from outside of the canvas that the pan gesture has to work simultaneously with + const panGestureExternalGestures = [pagerGesture, externalGestureHandler].filter((gesture) => gesture !== undefined); + + const panGesture = usePanGesture({ + canvasSize, + contentSize, + zoomScale, + totalScale, + offsetX, + offsetY, + panTranslateX, + panTranslateY, + stopAnimation, + shouldDisableTransformationGestures, + isSwipingDownToClose, + shouldDisableSwipeDownToClose, + onSwipeDown, + simultaneousWith: panGestureExternalGestures, + }); + + // The tap gestures are created after the pan gesture, because the single tap waits for it to fail. + // They also declare that they can run simultaneously with the pan gesture, which is symmetric. + const {singleTapGesture, doubleTapGesture} = useTapGestures({ canvasSize, contentSize, zoomRange, @@ -213,31 +231,10 @@ function MultiGestureCanvas({ isTransformGestureActive, onTap, shouldDisableTransformationGestures, + panGesture, }); - const singleTapGesture = baseSingleTapGesture.requireExternalGestureToFail(doubleTapGesture, panGestureRef); - - const panGestureSimultaneousList = useMemo( - () => (pagerRef === undefined ? [singleTapGesture, doubleTapGesture] : [pagerRef as unknown as Exclude, singleTapGesture, doubleTapGesture]), - [doubleTapGesture, pagerRef, singleTapGesture], - ); - const panGesture = usePanGesture({ - canvasSize, - contentSize, - zoomScale, - totalScale, - offsetX, - offsetY, - panTranslateX, - panTranslateY, - stopAnimation, - shouldDisableTransformationGestures, - isSwipingDownToClose, - shouldDisableSwipeDownToClose, - onSwipeDown, - }) - .simultaneousWithExternalGesture(...panGestureSimultaneousList) - .withRef(panGestureRef); + const pinchSimultaneousGestures = [panGesture, singleTapGesture, doubleTapGesture]; const pinchGesture = usePinchGesture({ canvasSize, @@ -252,7 +249,11 @@ function MultiGestureCanvas({ onScaleChanged, isTransformGestureActive, shouldDisableTransformationGestures, - }).simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture); + simultaneousWith: pinchSimultaneousGestures, + }); + + const tapsAndPanGesture = useCompetingGestures(singleTapGesture, doubleTapGesture, panGesture); + const canvasGesture = useSimultaneousGestures(pinchGesture, tapsAndPanGesture); // Trigger a reset when the canvas gets inactive, but only if it was already mounted before const mounted = useRef(false); @@ -289,14 +290,12 @@ function MultiGestureCanvas({ const containerStyles = useMemo(() => [styles.flex1, StyleUtils.getMultiGestureCanvasContainerStyle(canvasSize.width)], [StyleUtils, canvasSize.width, styles.flex1]); - const panGestureWrapper = externalGestureHandler ? panGesture.simultaneousWithExternalGesture(externalGestureHandler) : panGesture; - return ( - + { diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index 9b1f5695cb7d..a65b0cd34d6e 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -1,10 +1,12 @@ import {isMobile} from '@libs/Browser'; -import type {LegacyPanGesture} from 'react-native-gesture-handler'; +import type {PanGesture, PanGestureConfig} from 'react-native-gesture-handler'; import {useCallback} from 'react'; import {Dimensions} from 'react-native'; -import {Gesture} from 'react-native-gesture-handler'; +// Aliased because this hook has the same name. The worklets Babel plugin only turns the callbacks of a Gesture Handler +// hook into worklets automatically when the hook is called by its own name, so every callback below is marked with 'worklet'. +import {GestureStateManager, usePanGesture as useGestureHandlerPanGesture} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -36,7 +38,10 @@ type UsePanGestureProps = Pick< | 'shouldDisableSwipeDownToClose' | 'onSwipeDown' | 'isSwipingDownToClose' ->; +> & { + /** Gestures outside of the canvas (e.g. the pager) that can run simultaneously with the pan */ + simultaneousWith: PanGestureConfig['simultaneousWith']; +}; const usePanGesture = ({ canvasSize, @@ -52,7 +57,8 @@ const usePanGesture = ({ isSwipingDownToClose, shouldDisableSwipeDownToClose, onSwipeDown, -}: UsePanGestureProps): LegacyPanGesture => { + simultaneousWith, +}: UsePanGestureProps): PanGesture => { // The content size after fitting it to the canvas and zooming const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.get(), [contentSize.width]); const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.get(), [contentSize.height]); @@ -181,16 +187,21 @@ const usePanGesture = ({ panVelocityY.set(0); }, [getBounds, isSwipingDownToClose, offsetX, offsetY, onSwipeDown, panTranslateX, panTranslateY, panVelocityX, panVelocityY, shouldDisableSwipeDownToClose, zoomScale]); - const panGesture = Gesture.Pan() - .manualActivation(true) - .averageTouches(true) - .onTouchesUp(() => { + const panGesture = useGestureHandlerPanGesture({ + manualActivation: true, + averageTouches: true, + simultaneousWith, + onTouchesUp: () => { + 'worklet'; + previousTouch.set(null); - }) - .onTouchesMove((evt, state) => { + }, + onTouchesMove: (evt) => { + 'worklet'; + // We only allow panning when the content is zoomed in if (zoomScale.get() > 1 && !shouldDisableTransformationGestures.get()) { - state.activate(); + GestureStateManager.activate(evt.handlerTag); } // TODO: this needs tuning to work properly @@ -200,7 +211,7 @@ const usePanGesture = ({ const velocityY = (evt.allTouches.at(0)?.y ?? 0) - previousTouchValue.y; if (Math.abs(velocityY) > velocityX && velocityY > 20) { - state.activate(); + GestureStateManager.activate(evt.handlerTag); isSwipingDownToClose.set(true); previousTouch.set(null); @@ -215,11 +226,15 @@ const usePanGesture = ({ y: evt.allTouches.at(0)?.y ?? 0, }); } - }) - .onStart(() => { + }, + onActivate: () => { + 'worklet'; + stopAnimation(); - }) - .onChange((evt) => { + }, + onUpdate: (evt) => { + 'worklet'; + // Since we're running both pinch and pan gesture handlers simultaneously, // we need to make sure that we don't pan when we pinch since we track it as pinch focal gesture. if (evt.numberOfPointers > 1) { @@ -238,8 +253,10 @@ const usePanGesture = ({ if (enableSwipeDownToClose.get() || isSwipingDownToClose.get()) { panTranslateY.set((value) => value + evt.changeY); } - }) - .onEnd(() => { + }, + onDeactivate: () => { + 'worklet'; + // Add pan translation to total offset and reset gesture variables offsetX.set((value) => value + panTranslateX.get()); offsetY.set((value) => value + panTranslateY.get()); @@ -255,7 +272,8 @@ const usePanGesture = ({ } finishPanGesture(); - }); + }, + }); return panGesture; }; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/usePinchGesture.ts index f2877fd3bd13..1abbc94ab125 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/usePinchGesture.ts @@ -1,7 +1,9 @@ -import type {LegacyPinchGesture} from 'react-native-gesture-handler'; +import type {PinchGesture, PinchGestureConfig} from 'react-native-gesture-handler'; import {useCallback, useEffect, useState} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +// Aliased because this hook has the same name. The worklets Babel plugin only turns the callbacks of a Gesture Handler +// hook into worklets automatically when the hook is called by its own name, so every callback below is marked with 'worklet'. +import {GestureStateManager, usePinchGesture as useGestureHandlerPinchGesture} from 'react-native-gesture-handler'; import {useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -23,7 +25,10 @@ type UsePinchGestureProps = Pick< | 'isTransformGestureActive' | 'stopAnimation' | 'onScaleChanged' ->; +> & { + /** Gestures of the canvas that can run simultaneously with the pinch */ + simultaneousWith: PinchGestureConfig['simultaneousWith']; +}; const usePinchGesture = ({ canvasSize, @@ -38,7 +43,8 @@ const usePinchGesture = ({ isTransformGestureActive, stopAnimation, onScaleChanged, -}: UsePinchGestureProps): LegacyPinchGesture => { + simultaneousWith, +}: UsePinchGestureProps): PinchGesture => { // The current pinch gesture event scale const currentPinchScale = useSharedValue(1); @@ -113,13 +119,10 @@ const usePinchGesture = ({ setPinchEnabled(true); }, [pinchEnabled]); - const pinchGesture = Gesture.Pinch() - .enabled(pinchEnabled) - // The first argument is not used, but must be defined - .onTouchesDown((_evt, state) => { - // react-compiler optimization unintentionally make all the callbacks run on the JS thread. - // Adding the worklet directive here will make all the callbacks run on UI thread back. - + const pinchGesture = useGestureHandlerPinchGesture({ + enabled: pinchEnabled, + simultaneousWith, + onTouchesDown: (evt) => { 'worklet'; // We don't want to activate pinch gesture when we are swiping in the pager @@ -127,9 +130,11 @@ const usePinchGesture = ({ return; } - state.fail(); - }) - .onStart((evt) => { + GestureStateManager.fail(evt.handlerTag); + }, + onActivate: (evt) => { + 'worklet'; + stopAnimation(); isPinchSettling.set(false); isTransformGestureActive.set(true); @@ -138,8 +143,8 @@ const usePinchGesture = ({ const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); pinchOrigin.x.set(adjustedFocal.x); pinchOrigin.y.set(adjustedFocal.y); - }) - .onChange((evt) => { + }, + onUpdate: (evt) => { 'worklet'; // Disable the pinch gesture if one finger is released, @@ -174,8 +179,10 @@ const usePinchGesture = ({ pinchBounceTranslateX.set(newPinchTranslateX - pinchTranslateX.get()); pinchBounceTranslateY.set(newPinchTranslateY - pinchTranslateY.get()); } - }) - .onEnd(() => { + }, + onDeactivate: () => { + 'worklet'; + // Add pinch translation to total offset and reset gesture variables offsetX.set((value) => value + pinchTranslateX.get()); offsetY.set((value) => value + pinchTranslateY.get()); @@ -204,14 +211,17 @@ const usePinchGesture = ({ pinchScale.set(zoomScale.get()); finishTransformGesture(); } - }) - .onFinalize(() => { + }, + onFinalize: () => { + 'worklet'; + if (isPinchSettling.get()) { return; } isTransformGestureActive.set(false); - }); + }, + }); return pinchGesture; }; diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts index ebc3b64cabee..69fbab40bc38 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -1,7 +1,7 @@ -import type {LegacyTapGesture} from 'react-native-gesture-handler'; +import type {PanGesture, TapGesture} from 'react-native-gesture-handler'; import {useCallback, useMemo} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {GestureStateManager, useTapGesture} from 'react-native-gesture-handler'; import {withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -27,7 +27,10 @@ type UseTapGesturesProps = Pick< | 'stopAnimation' | 'onScaleChanged' | 'onTap' ->; +> & { + /** The pan gesture of the canvas. A single tap waits for it to fail, and both taps can run simultaneously with it */ + panGesture: PanGesture; +}; const useTapGestures = ({ canvasSize, @@ -45,7 +48,8 @@ const useTapGestures = ({ isTransformGestureActive, onScaleChanged, onTap, -}: UseTapGesturesProps): {singleTapGesture: LegacyTapGesture; doubleTapGesture: LegacyTapGesture} => { + panGesture, +}: UseTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { // The content size after scaling it with minimum scale to fit the content into the canvas const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); @@ -132,21 +136,21 @@ const useTapGestures = ({ [stopAnimation, isTransformGestureActive, canvasSize.width, canvasSize.height, scaledContentWidth, scaledContentHeight, doubleTapScale, offsetX, offsetY, zoomScale, pinchScale], ); - const doubleTapGesture = Gesture.Tap() - // The first argument is not used, but must be defined - .onTouchesDown((_evt, state) => { + const doubleTapGesture = useTapGesture({ + numberOfTaps: 2, + maxDelay: 150, + maxDistance: 20, + simultaneousWith: panGesture, + onTouchesDown: (evt) => { 'worklet'; if (!shouldDisableTransformationGestures.get()) { return; } - state.fail(); - }) - .numberOfTaps(2) - .maxDelay(150) - .maxDistance(20) - .onEnd((evt) => { + GestureStateManager.fail(evt.handlerTag); + }, + onDeactivate: (evt) => { 'worklet'; const triggerScaleChangedEvent = () => { @@ -168,25 +172,29 @@ const useTapGestures = ({ } else { zoomToCoordinates(evt.x, evt.y, triggerScaleChangedEvent); } - }); - - const singleTapGesture = Gesture.Tap() - .numberOfTaps(1) - .maxDuration(125) - .onBegin(() => { + }, + }); + + const singleTapGesture = useTapGesture({ + numberOfTaps: 1, + maxDuration: 125, + requireToFail: [doubleTapGesture, panGesture], + simultaneousWith: panGesture, + onBegin: () => { 'worklet'; stopAnimation(); - }) - .onFinalize((_evt, success) => { + }, + onFinalize: (evt) => { 'worklet'; - if (!success || onTap === undefined) { + if (evt.canceled || onTap === undefined) { return; } scheduleOnRN(onTap); - }); + }, + }); return {singleTapGesture, doubleTapGesture}; }; diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index 22defa08a990..b2b906953c1c 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -132,7 +132,7 @@ function MobilePDFGestureCanvas({children, onScaleChanged}: MobilePDFGestureCanv canvasSize={canvasSize} contentSize={canvasSize} zoomRange={PDF_ZOOM_RANGE} - pagerRef={state?.pagerRef} + pagerGesture={state?.pagerGesture} isUsedInCarousel={!!state?.pagerRef} shouldDisableTransformationGestures={state?.isPagerScrolling ?? isPagerScrollingFallback} isTransformGestureActive={isTransformGestureActive} From 368ec0b16d29f2fc42b5c17e9695dd7c2f3c4027 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 9 Oct 2026 13:08:47 +0200 Subject: [PATCH 2/8] Revert eslint-seatbelt baseline changes OSBotify tightens the baseline after merge, so editing it here only causes conflicts between the migration PRs. --- config/eslint/eslint.seatbelt.tsv | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index f922acccb4ff..333f2618b57e 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -66,10 +66,17 @@ "../../src/components/AttachmentPicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPicker/index.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 3 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/LegacyComposedGesture" 1 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "react-hooks/refs" 3 "../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Native" 2 "../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/Attachments/AttachmentCarousel/Pager/types.ts" "@typescript-eslint/no-deprecated/GestureType" 2 "../../src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts" "no-restricted-syntax" 1 +"../../src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/Attachments/AttachmentView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 @@ -242,9 +249,23 @@ "../../src/components/MultifactorAuthentication/Context/stateReducer.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/Context/usePromptContent.ts" "react-hooks/refs" 10 "../../src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Simultaneous" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureRef" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 +"../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/refs" 2 +"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 +"../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pinch" 2 +"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/LegacyPinchGesture" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 +"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 4 +"../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/LegacyTapGesture" 2 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 From 6cc35c615a7e0cdf43c90f234654689edd5ceb98 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 16 Oct 2026 17:35:36 +0200 Subject: [PATCH 3/8] rename our custom hooks --- src/components/MultiGestureCanvas/index.tsx | 12 +++++----- ...sePanGesture.ts => useCanvasPanGesture.ts} | 24 +++++-------------- ...nchGesture.ts => useCanvasPinchGesture.ts} | 20 +++++----------- ...TapGestures.ts => useCanvasTapGestures.ts} | 8 +++---- 4 files changed, 22 insertions(+), 42 deletions(-) rename src/components/MultiGestureCanvas/{usePanGesture.ts => useCanvasPanGesture.ts} (94%) rename src/components/MultiGestureCanvas/{usePinchGesture.ts => useCanvasPinchGesture.ts} (92%) rename src/components/MultiGestureCanvas/{useTapGestures.ts => useCanvasTapGestures.ts} (97%) diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 195e5ccb9a3a..72814366e879 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -16,9 +16,9 @@ import {scheduleOnUI} from 'react-native-worklets'; import type {OnScaleChangedCallback, OnSwipeDownCallback, OnTapCallback, ZoomRange} from './types'; import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG} from './constants'; -import usePanGesture from './usePanGesture'; -import usePinchGesture from './usePinchGesture'; -import useTapGestures from './useTapGestures'; +import useCanvasPanGesture from './useCanvasPanGesture'; +import useCanvasPinchGesture from './useCanvasPinchGesture'; +import useCanvasTapGestures from './useCanvasTapGestures'; import * as MultiGestureCanvasUtils from './utils'; type MultiGestureCanvasProps = ChildrenProps & { @@ -196,7 +196,7 @@ function MultiGestureCanvas({ // Gestures from outside of the canvas that the pan gesture has to work simultaneously with const panGestureExternalGestures = [pagerGesture, externalGestureHandler].filter((gesture) => gesture !== undefined); - const panGesture = usePanGesture({ + const panGesture = useCanvasPanGesture({ canvasSize, contentSize, zoomScale, @@ -215,7 +215,7 @@ function MultiGestureCanvas({ // The tap gestures are created after the pan gesture, because the single tap waits for it to fail. // They also declare that they can run simultaneously with the pan gesture, which is symmetric. - const {singleTapGesture, doubleTapGesture} = useTapGestures({ + const {singleTapGesture, doubleTapGesture} = useCanvasTapGestures({ canvasSize, contentSize, zoomRange, @@ -236,7 +236,7 @@ function MultiGestureCanvas({ const pinchSimultaneousGestures = [panGesture, singleTapGesture, doubleTapGesture]; - const pinchGesture = usePinchGesture({ + const pinchGesture = useCanvasPinchGesture({ canvasSize, zoomScale, zoomRange, diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/useCanvasPanGesture.ts similarity index 94% rename from src/components/MultiGestureCanvas/usePanGesture.ts rename to src/components/MultiGestureCanvas/useCanvasPanGesture.ts index a65b0cd34d6e..84b1cd4d1bca 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPanGesture.ts @@ -4,9 +4,7 @@ import type {PanGesture, PanGestureConfig} from 'react-native-gesture-handler'; import {useCallback} from 'react'; import {Dimensions} from 'react-native'; -// Aliased because this hook has the same name. The worklets Babel plugin only turns the callbacks of a Gesture Handler -// hook into worklets automatically when the hook is called by its own name, so every callback below is marked with 'worklet'. -import {GestureStateManager, usePanGesture as useGestureHandlerPanGesture} from 'react-native-gesture-handler'; +import {GestureStateManager, usePanGesture} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -23,7 +21,7 @@ const SCREEN_HEIGHT = Dimensions.get('screen').height; const SNAP_POINT = SCREEN_HEIGHT / 4; const SNAP_POINT_HIDDEN = SCREEN_HEIGHT / 1.2; -type UsePanGestureProps = Pick< +type UseCanvasPanGestureProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'contentSize' @@ -43,7 +41,7 @@ type UsePanGestureProps = Pick< simultaneousWith: PanGestureConfig['simultaneousWith']; }; -const usePanGesture = ({ +const useCanvasPanGesture = ({ canvasSize, contentSize, zoomScale, @@ -58,7 +56,7 @@ const usePanGesture = ({ shouldDisableSwipeDownToClose, onSwipeDown, simultaneousWith, -}: UsePanGestureProps): PanGesture => { +}: UseCanvasPanGestureProps): PanGesture => { // The content size after fitting it to the canvas and zooming const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.get(), [contentSize.width]); const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.get(), [contentSize.height]); @@ -187,18 +185,14 @@ const usePanGesture = ({ panVelocityY.set(0); }, [getBounds, isSwipingDownToClose, offsetX, offsetY, onSwipeDown, panTranslateX, panTranslateY, panVelocityX, panVelocityY, shouldDisableSwipeDownToClose, zoomScale]); - const panGesture = useGestureHandlerPanGesture({ + const panGesture = usePanGesture({ manualActivation: true, averageTouches: true, simultaneousWith, onTouchesUp: () => { - 'worklet'; - previousTouch.set(null); }, onTouchesMove: (evt) => { - 'worklet'; - // We only allow panning when the content is zoomed in if (zoomScale.get() > 1 && !shouldDisableTransformationGestures.get()) { GestureStateManager.activate(evt.handlerTag); @@ -228,13 +222,9 @@ const usePanGesture = ({ } }, onActivate: () => { - 'worklet'; - stopAnimation(); }, onUpdate: (evt) => { - 'worklet'; - // Since we're running both pinch and pan gesture handlers simultaneously, // we need to make sure that we don't pan when we pinch since we track it as pinch focal gesture. if (evt.numberOfPointers > 1) { @@ -255,8 +245,6 @@ const usePanGesture = ({ } }, onDeactivate: () => { - 'worklet'; - // Add pan translation to total offset and reset gesture variables offsetX.set((value) => value + panTranslateX.get()); offsetY.set((value) => value + panTranslateY.get()); @@ -278,4 +266,4 @@ const usePanGesture = ({ return panGesture; }; -export default usePanGesture; +export default useCanvasPanGesture; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts similarity index 92% rename from src/components/MultiGestureCanvas/usePinchGesture.ts rename to src/components/MultiGestureCanvas/useCanvasPinchGesture.ts index 1abbc94ab125..e1b492ca439e 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts @@ -1,9 +1,7 @@ import type {PinchGesture, PinchGestureConfig} from 'react-native-gesture-handler'; import {useCallback, useEffect, useState} from 'react'; -// Aliased because this hook has the same name. The worklets Babel plugin only turns the callbacks of a Gesture Handler -// hook into worklets automatically when the hook is called by its own name, so every callback below is marked with 'worklet'. -import {GestureStateManager, usePinchGesture as useGestureHandlerPinchGesture} from 'react-native-gesture-handler'; +import {GestureStateManager, usePinchGesture} from 'react-native-gesture-handler'; import {useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -11,7 +9,7 @@ import type {MultiGestureCanvasVariables} from './types'; import {SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; -type UsePinchGestureProps = Pick< +type UseCanvasPinchGestureProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'zoomScale' @@ -30,7 +28,7 @@ type UsePinchGestureProps = Pick< simultaneousWith: PinchGestureConfig['simultaneousWith']; }; -const usePinchGesture = ({ +const useCanvasPinchGesture = ({ canvasSize, zoomScale, zoomRange, @@ -44,7 +42,7 @@ const usePinchGesture = ({ stopAnimation, onScaleChanged, simultaneousWith, -}: UsePinchGestureProps): PinchGesture => { +}: UseCanvasPinchGestureProps): PinchGesture => { // The current pinch gesture event scale const currentPinchScale = useSharedValue(1); @@ -119,7 +117,7 @@ const usePinchGesture = ({ setPinchEnabled(true); }, [pinchEnabled]); - const pinchGesture = useGestureHandlerPinchGesture({ + const pinchGesture = usePinchGesture({ enabled: pinchEnabled, simultaneousWith, onTouchesDown: (evt) => { @@ -133,8 +131,6 @@ const usePinchGesture = ({ GestureStateManager.fail(evt.handlerTag); }, onActivate: (evt) => { - 'worklet'; - stopAnimation(); isPinchSettling.set(false); isTransformGestureActive.set(true); @@ -181,8 +177,6 @@ const usePinchGesture = ({ } }, onDeactivate: () => { - 'worklet'; - // Add pinch translation to total offset and reset gesture variables offsetX.set((value) => value + pinchTranslateX.get()); offsetY.set((value) => value + pinchTranslateY.get()); @@ -213,8 +207,6 @@ const usePinchGesture = ({ } }, onFinalize: () => { - 'worklet'; - if (isPinchSettling.get()) { return; } @@ -226,4 +218,4 @@ const usePinchGesture = ({ return pinchGesture; }; -export default usePinchGesture; +export default useCanvasPinchGesture; diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts similarity index 97% rename from src/components/MultiGestureCanvas/useTapGestures.ts rename to src/components/MultiGestureCanvas/useCanvasTapGestures.ts index 69fbab40bc38..4cc6a8c8c151 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts @@ -10,7 +10,7 @@ import type {MultiGestureCanvasVariables} from './types'; import {DOUBLE_TAP_SCALE, SPRING_CONFIG} from './constants'; import * as MultiGestureCanvasUtils from './utils'; -type UseTapGesturesProps = Pick< +type UseCanvasTapGesturesProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'contentSize' @@ -32,7 +32,7 @@ type UseTapGesturesProps = Pick< panGesture: PanGesture; }; -const useTapGestures = ({ +const useCanvasTapGestures = ({ canvasSize, contentSize, zoomRange, @@ -49,7 +49,7 @@ const useTapGestures = ({ onScaleChanged, onTap, panGesture, -}: UseTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { +}: UseCanvasTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { // The content size after scaling it with minimum scale to fit the content into the canvas const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); @@ -199,4 +199,4 @@ const useTapGestures = ({ return {singleTapGesture, doubleTapGesture}; }; -export default useTapGestures; +export default useCanvasTapGestures; From 14f6eece07735019a15f22cd8c28c94adcc1eb7e Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 16 Oct 2026 18:09:49 +0200 Subject: [PATCH 4/8] rename lines in tsv --- config/eslint/eslint.seatbelt.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 7be147cfcd32..07621153b6c7 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -269,15 +269,15 @@ "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/refs" 2 +"../../src/components/MultiGestureCanvas/useCanvasPanGesture.ts" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/useCanvasPinchGesture.ts" "react-hooks/set-state-in-effect" 1 +"../../src/components/MultiGestureCanvas/useCanvasTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 -"../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pinch" 2 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/LegacyPinchGesture" 1 -"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 4 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/LegacyTapGesture" 2 -"../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/NumberWithSymbolForm.tsx" "react-hooks/set-state-in-effect" 2 From 84a6f32d817a5730dabfa00fd7e656a1c503c32a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 19 Oct 2026 12:27:03 +0200 Subject: [PATCH 5/8] remove worklet directive when it's not needed --- .../AttachmentCarousel/AttachmentCarouselView/index.tsx | 2 +- .../MultiGestureCanvas/useCanvasPinchGesture.ts | 4 ---- src/components/MultiGestureCanvas/useCanvasTapGestures.ts | 8 -------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index ed902549d996..ec704c3e6d87 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -155,7 +155,7 @@ function AttachmentCarouselView({ [cellWidth], ); - /** Pan gesture handing swiping through attachments on touch screen devices */ + /** Pan gesture handling swiping through attachments on touch screen devices */ const pan = usePanGesture({ enabled: canUseTouchScreen, onUpdate: ({translationX}) => { diff --git a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts index e1b492ca439e..855d3f6fb71b 100644 --- a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts @@ -121,8 +121,6 @@ const useCanvasPinchGesture = ({ enabled: pinchEnabled, simultaneousWith, onTouchesDown: (evt) => { - 'worklet'; - // We don't want to activate pinch gesture when we are swiping in the pager if (!shouldDisableTransformationGestures.get()) { return; @@ -141,8 +139,6 @@ const useCanvasPinchGesture = ({ pinchOrigin.y.set(adjustedFocal.y); }, onUpdate: (evt) => { - 'worklet'; - // Disable the pinch gesture if one finger is released, // to prevent the content from shaking/jumping if (evt.numberOfPointers !== 2) { diff --git a/src/components/MultiGestureCanvas/useCanvasTapGestures.ts b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts index 4cc6a8c8c151..89bfc18578a7 100644 --- a/src/components/MultiGestureCanvas/useCanvasTapGestures.ts +++ b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts @@ -142,8 +142,6 @@ const useCanvasTapGestures = ({ maxDistance: 20, simultaneousWith: panGesture, onTouchesDown: (evt) => { - 'worklet'; - if (!shouldDisableTransformationGestures.get()) { return; } @@ -151,8 +149,6 @@ const useCanvasTapGestures = ({ GestureStateManager.fail(evt.handlerTag); }, onDeactivate: (evt) => { - 'worklet'; - const triggerScaleChangedEvent = () => { 'worklet'; @@ -181,13 +177,9 @@ const useCanvasTapGestures = ({ requireToFail: [doubleTapGesture, panGesture], simultaneousWith: panGesture, onBegin: () => { - 'worklet'; - stopAnimation(); }, onFinalize: (evt) => { - 'worklet'; - if (evt.canceled || onTap === undefined) { return; } From e2b022d744d0eb22bd4c26bd06991260e73eb278 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 21 Oct 2026 17:37:57 +0200 Subject: [PATCH 6/8] Create the pinch gesture before the pan gesture so that pan re-centers the content after pinch on web --- src/components/MultiGestureCanvas/index.tsx | 35 +++++++++---------- .../useCanvasPinchGesture.ts | 9 ++--- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 72814366e879..fa11a99b3da5 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -193,6 +193,23 @@ function MultiGestureCanvas({ [offsetX, offsetY, panTranslateX, panTranslateY, pinchScale, pinchTranslateX, pinchTranslateY, stopAnimation, zoomScale], ); + // Pinch must be created before pan: on web, handlers end in creation order, and pan has to end last + // to re-center the content using the offset that already includes the pinch translation. + const pinchGesture = useCanvasPinchGesture({ + canvasSize, + zoomScale, + zoomRange, + offsetX, + offsetY, + pinchTranslateX, + pinchTranslateY, + pinchScale, + stopAnimation, + onScaleChanged, + isTransformGestureActive, + shouldDisableTransformationGestures, + }); + // Gestures from outside of the canvas that the pan gesture has to work simultaneously with const panGestureExternalGestures = [pagerGesture, externalGestureHandler].filter((gesture) => gesture !== undefined); @@ -234,24 +251,6 @@ function MultiGestureCanvas({ panGesture, }); - const pinchSimultaneousGestures = [panGesture, singleTapGesture, doubleTapGesture]; - - const pinchGesture = useCanvasPinchGesture({ - canvasSize, - zoomScale, - zoomRange, - offsetX, - offsetY, - pinchTranslateX, - pinchTranslateY, - pinchScale, - stopAnimation, - onScaleChanged, - isTransformGestureActive, - shouldDisableTransformationGestures, - simultaneousWith: pinchSimultaneousGestures, - }); - const tapsAndPanGesture = useCompetingGestures(singleTapGesture, doubleTapGesture, panGesture); const canvasGesture = useSimultaneousGestures(pinchGesture, tapsAndPanGesture); diff --git a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts index 855d3f6fb71b..68de69fc6ead 100644 --- a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts @@ -1,4 +1,4 @@ -import type {PinchGesture, PinchGestureConfig} from 'react-native-gesture-handler'; +import type {PinchGesture} from 'react-native-gesture-handler'; import {useCallback, useEffect, useState} from 'react'; import {GestureStateManager, usePinchGesture} from 'react-native-gesture-handler'; @@ -23,10 +23,7 @@ type UseCanvasPinchGestureProps = Pick< | 'isTransformGestureActive' | 'stopAnimation' | 'onScaleChanged' -> & { - /** Gestures of the canvas that can run simultaneously with the pinch */ - simultaneousWith: PinchGestureConfig['simultaneousWith']; -}; +>; const useCanvasPinchGesture = ({ canvasSize, @@ -41,7 +38,6 @@ const useCanvasPinchGesture = ({ isTransformGestureActive, stopAnimation, onScaleChanged, - simultaneousWith, }: UseCanvasPinchGestureProps): PinchGesture => { // The current pinch gesture event scale const currentPinchScale = useSharedValue(1); @@ -119,7 +115,6 @@ const useCanvasPinchGesture = ({ const pinchGesture = usePinchGesture({ enabled: pinchEnabled, - simultaneousWith, onTouchesDown: (evt) => { // We don't want to activate pinch gesture when we are swiping in the pager if (!shouldDisableTransformationGestures.get()) { From a7546bf26ae9f8794b4942ed905e05a7867025e2 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 22 Oct 2026 14:38:30 +0200 Subject: [PATCH 7/8] fix types --- .../AttachmentCarousel/AttachmentCarouselView/index.tsx | 2 +- src/components/Attachments/AttachmentCarousel/Pager/types.ts | 3 +-- src/components/MultiGestureCanvas/index.tsx | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index 43c9d7776752..b269e7c53b2c 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -77,7 +77,7 @@ function AttachmentCarouselView({ const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); - const scrollRef = useAnimatedRef>>(); + const scrollRef = useAnimatedRef>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); diff --git a/src/components/Attachments/AttachmentCarousel/Pager/types.ts b/src/components/Attachments/AttachmentCarousel/Pager/types.ts index 0d33a364a958..57ff2e861646 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/types.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/types.ts @@ -1,7 +1,6 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type {ForwardedRef} from 'react'; -import type {ListRenderItemInfo} from 'react-native'; import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; import type Animated from 'react-native-reanimated'; @@ -30,7 +29,7 @@ type AttachmentCarouselPagerStateContextType = { activePage: number; /** Ref to the pager: the PagerView on native and the attachment list on web */ - pagerRef?: ForwardedRef | AnimatedRef>>; + pagerRef?: ForwardedRef | AnimatedRef>; /** The pan gesture that swipes between attachments on web. The pan gesture of the attachment needs to work simultaneously with it */ pagerGesture?: PanGesture; diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index dce75a36ef39..fa11a99b3da5 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,5 +1,3 @@ -import type {AttachmentCarouselPagerStateContextType} from '@components/Attachments/AttachmentCarousel/Pager/types'; - import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; From 89f17e0727addc06065915a8ff23fa4ebdf83669 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 22 Oct 2026 14:55:16 +0200 Subject: [PATCH 8/8] fix test --- tests/unit/AttachmentCarouselViewTest.tsx | 35 +++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/tests/unit/AttachmentCarouselViewTest.tsx b/tests/unit/AttachmentCarouselViewTest.tsx index 27d9a2beb652..80e37c8bc79a 100644 --- a/tests/unit/AttachmentCarouselViewTest.tsx +++ b/tests/unit/AttachmentCarouselViewTest.tsx @@ -232,29 +232,24 @@ describe('AttachmentCarouselView web', () => { dismiss.mockRestore(); }); - it('shares the exact undefined-initialized ref with the real pan gesture and retains it on rerender', () => { + it('shares the real pan gesture and the list ref with pager consumers and retains them on rerender', () => { // Given the production gesture passed to the detector, with only detector rendering replaced const {props, rerender} = renderCarousel(); const detectorProps = jest.mocked(GestureDetector).mock.calls.at(-1)?.[0]; expect(detectorProps).toBeDefined(); - if (!detectorProps || !pagerState || !('toGestureArray' in detectorProps.gesture)) { + const gesture = pagerState?.pagerGesture; + if (!detectorProps || !pagerState || !gesture) { throw new Error('Expected the real detector gesture and pager context'); } - const gestures = detectorProps.gesture.toGestureArray(); - expect(gestures).toHaveLength(1); - const gesture = gestures.at(0); - expect(gesture).toBeDefined(); - if (!gesture) { - throw new Error('Expected the single pan gesture'); - } - expect(gesture.handlerName).toBe('PanGestureHandler'); - const contextRef = pagerState.pagerRef; - expect(gesture.config.ref).toBe(contextRef); - expect(gesture.config.ref?.current).toBeUndefined(); - - // When Gesture Handler initializes the real gesture it writes the same ref exposed to pager consumers - gesture.initialize(); - expect(gesture.config.ref?.current).toBe(gesture); + + // Then pager consumers receive the exact enabled pan gesture given to the detector, and the list ref + expect(detectorProps.gesture).toBe(gesture); + expect(gesture.type).toBe('PanGestureHandler'); + expect(gesture.config.enabled).toBe(true); + expect(pagerState.pagerRef).toBe(mockScrollRef); + const handlerTag = gesture.handlerTag; + + // When the carousel rerenders with unrelated props rerender( { />, ); - // Then the provider keeps the original ref object rather than a snapshot or a second ref - expect(pagerState.pagerRef).toBe(contextRef); + // Then the provider keeps the same list ref and the gesture keeps its native handler identity + expect(pagerState.pagerRef).toBe(mockScrollRef); + expect(pagerState.pagerGesture?.handlerTag).toBe(handlerTag); + expect(jest.mocked(GestureDetector).mock.calls.at(-1)?.[0].gesture).toBe(pagerState.pagerGesture); }); it('enforces arrow bounds and bypasses the detector on a mouse device', () => {