From 00dbac8dd10d25200ab944fa006e32a20d92894a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 22 Oct 2026 13:42:49 +0200 Subject: [PATCH 1/2] Disable Reanimated in components --- .../docs/components/buttons.mdx | 2 ++ .../docs/components/wrapped-components.mdx | 2 ++ .../src/__tests__/api_v3.test.tsx | 34 +++++++++++++++++++ .../src/v3/createNativeWrapper.tsx | 10 +++--- .../src/v3/hooks/utils/propsWhiteList.ts | 5 ++- .../src/v3/types/NativeWrapperType.ts | 7 +++- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/buttons.mdx b/packages/docs-gesture-handler/docs/components/buttons.mdx index 34552ad0d6..5d2c05e0e7 100644 --- a/packages/docs-gesture-handler/docs/components/buttons.mdx +++ b/packages/docs-gesture-handler/docs/components/buttons.mdx @@ -27,6 +27,8 @@ Gesture Handler library exposes components that render native touchable elements On top of that all the buttons are wrapped with [`Native gesture`](/docs/gestures/use-native-gesture) and therefore allow for all its [properties](/docs/gestures/use-native-gesture#config) to be applied to them. +Button callbacks always run on the JS thread. The Reanimated-specific configuration (`disableReanimated`, `runOnJS`) is not available on buttons, and passing a worklet as a callback does not move it to the UI thread. + ## RawButton diff --git a/packages/docs-gesture-handler/docs/components/wrapped-components.mdx b/packages/docs-gesture-handler/docs/components/wrapped-components.mdx index 960c349a9a..8aa2ec93a7 100644 --- a/packages/docs-gesture-handler/docs/components/wrapped-components.mdx +++ b/packages/docs-gesture-handler/docs/components/wrapped-components.mdx @@ -7,6 +7,8 @@ sidebar_position: 5 Some components come with a [`Native`](/docs/gestures/use-native-gesture) gesture pre-applied. This allows them to participate in the gesture recognition process. Have a look at the example below. +The callbacks of the pre-applied gesture always run on the JS thread; the Reanimated-specific configuration (`disableReanimated`, `runOnJS`) is not available on these components. + ```tsx import { useState } from 'react'; import { Switch } from 'react-native'; diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 14e83c7750..508b2fc194 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -267,6 +267,40 @@ describe('[API v3] Components', () => { expect(pressFn).toHaveBeenCalledTimes(1); }); + // BaseButton adds plain JS callbacks of its own, so a worklet passed by the + // user must not move the gesture to the Reanimated detector (#4628). + test('Rect Button with a worklet callback stays on the JS thread', () => { + const pressFn = jest.fn(); + const onTouchesDown = Object.assign(jest.fn(), { __workletHash: 1 }); + + render( + + + + ); + + const nativeGesture = getByGestureTestId('btn'); + + expect(nativeGesture.config.shouldUseReanimatedDetector).toBe(false); + expect(nativeGesture.config.disableReanimated).toBe(true); + + act(() => { + fireGestureHandler(nativeGesture, [ + { oldState: State.UNDETERMINED, state: State.BEGAN }, + { oldState: State.BEGAN, state: State.ACTIVE }, + { oldState: State.ACTIVE, state: State.END }, + ]); + }); + + expect(pressFn).toHaveBeenCalledTimes(1); + }); + describe('ScrollView', () => { test('handles responder event passed through Pressable for keyboardShouldPersistTaps handled', async () => { const { UNSAFE_getAllByType } = render( diff --git a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx index 75147f834d..60ba4641be 100644 --- a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx +++ b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx @@ -4,6 +4,7 @@ import { GestureDetectorType, InterceptingGestureDetector } from './detectors'; import { NativeDetector } from './detectors/NativeDetector'; import { VirtualDetector } from './detectors/VirtualDetector/VirtualDetector'; import { useNativeGesture } from './hooks/gestures'; +import type { NativeGestureConfig } from './hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from './hooks/utils'; import type { NativeWrapperProperties, @@ -36,7 +37,7 @@ export default function createNativeWrapper( return res; }, { - gestureHandlerProps: { ...config }, // Watch out not to modify config + gestureHandlerProps: { ...config } as NativeGestureConfig, // Watch out not to modify config childProps: { enabled: props.enabled, hitSlop: props.hitSlop, @@ -45,9 +46,10 @@ export default function createNativeWrapper( } ); - if (gestureHandlerProps.disableReanimated === undefined) { - gestureHandlerProps.disableReanimated = true; - } + // Components built on top of the wrapper (e.g. BaseButton) add plain JS + // callbacks of their own, so the gesture must never use the Reanimated + // detector, whatever the caller passed. + gestureHandlerProps.disableReanimated = true; const native = useNativeGesture(gestureHandlerProps); diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index 8ce7c62e56..ad3c07a131 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -113,12 +113,15 @@ export const PropsWhiteLists = new Map< export const EMPTY_WHITE_LIST = new Set(); +// `disableReanimated` and `runOnJS` are not wrapper props, but route them to +// the gesture (where the wrapper overrides them) instead of the wrapped view. export const NativeWrapperProps = new Set< - keyof NativeWrapperProperties + keyof NativeWrapperProperties | 'disableReanimated' | 'runOnJS' >([ ...CommonConfig, ...HandlerCallbacks, ...NativeHandlerNativeProperties, ...ExternalRelationsConfig, 'disableReanimated', + 'runOnJS', ]); diff --git a/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts b/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts index db30a78c32..e87cd0b9a3 100644 --- a/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts +++ b/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts @@ -16,7 +16,12 @@ export type WrapperSpecificProperties = { ) => void; }; -export type NativeWrapperProperties = CommonGestureConfig & +// Wrapped components always run their callbacks on the JS thread, so the +// Reanimated-only config has no effect on them. +export type NativeWrapperProperties = Omit< + CommonGestureConfig, + 'disableReanimated' | 'runOnJS' +> & GestureCallbacks & NativeGestureNativeProperties & ExternalRelations & From a46dc80247ef4aad5a8e392fca1c795d6be96e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 27 Oct 2026 15:57:39 +0100 Subject: [PATCH 2/2] Add links in docs --- packages/docs-gesture-handler/docs/components/buttons.mdx | 2 +- .../docs-gesture-handler/docs/components/wrapped-components.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/buttons.mdx b/packages/docs-gesture-handler/docs/components/buttons.mdx index 5d2c05e0e7..854d65aa96 100644 --- a/packages/docs-gesture-handler/docs/components/buttons.mdx +++ b/packages/docs-gesture-handler/docs/components/buttons.mdx @@ -27,7 +27,7 @@ Gesture Handler library exposes components that render native touchable elements On top of that all the buttons are wrapped with [`Native gesture`](/docs/gestures/use-native-gesture) and therefore allow for all its [properties](/docs/gestures/use-native-gesture#config) to be applied to them. -Button callbacks always run on the JS thread. The Reanimated-specific configuration (`disableReanimated`, `runOnJS`) is not available on buttons, and passing a worklet as a callback does not move it to the UI thread. +Button callbacks always run on the JS thread. The Reanimated-specific configuration ([`disableReanimated`](/docs/fundamentals/reanimated-interactions#disablereanimated), [`runOnJS`](/docs/fundamentals/reanimated-interactions#runonjs)) is not available on buttons, and passing a worklet as a callback does not move it to the UI thread. ## RawButton diff --git a/packages/docs-gesture-handler/docs/components/wrapped-components.mdx b/packages/docs-gesture-handler/docs/components/wrapped-components.mdx index 8aa2ec93a7..49d20415b5 100644 --- a/packages/docs-gesture-handler/docs/components/wrapped-components.mdx +++ b/packages/docs-gesture-handler/docs/components/wrapped-components.mdx @@ -7,7 +7,7 @@ sidebar_position: 5 Some components come with a [`Native`](/docs/gestures/use-native-gesture) gesture pre-applied. This allows them to participate in the gesture recognition process. Have a look at the example below. -The callbacks of the pre-applied gesture always run on the JS thread; the Reanimated-specific configuration (`disableReanimated`, `runOnJS`) is not available on these components. +The callbacks of the pre-applied gesture always run on the JS thread. The Reanimated-specific configuration ([`disableReanimated`](/docs/fundamentals/reanimated-interactions#disablereanimated), [`runOnJS`](/docs/fundamentals/reanimated-interactions#runonjs)) is not available on these components. ```tsx import { useState } from 'react';