From 8091d71670c80365c01dec882fd80ede186a46c5 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 5 Aug 2026 13:46:24 +0200 Subject: [PATCH 1/7] init --- .../gesturehandler/core/GestureHandler.kt | 90 ++++-------- .../apple/RNGestureHandler.mm | 57 ++++---- .../RNGestureHandlerButtonComponentView.mm | 16 ++- .../src/__tests__/hitSlop.test.ts | 129 ++++++++++++++++++ .../components/GestureHandlerButton.web.tsx | 5 +- .../src/handlers/hitSlop.ts | 120 ++++++++++++++++ .../src/handlers/utils.ts | 6 +- .../src/v3/hooks/utils/configUtils.ts | 10 +- .../src/v3/hooks/utils/reanimatedUtils.ts | 21 +-- .../src/web/handlers/GestureHandler.ts | 100 ++++---------- .../src/web/interfaces.ts | 16 +-- 11 files changed, 369 insertions(+), 201 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts create mode 100644 packages/react-native-gesture-handler/src/handlers/hitSlop.ts diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 45af12e6a9..bd3a24862a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -11,7 +11,6 @@ import android.view.View import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableArray import com.facebook.react.uimanager.PixelUtil @@ -183,18 +182,6 @@ open class GestureHandler { hitSlop!![HIT_SLOP_BOTTOM_IDX] = bottomPad hitSlop!![HIT_SLOP_WIDTH_IDX] = width hitSlop!![HIT_SLOP_HEIGHT_IDX] = height - require(!(hitSlopSet(width) && hitSlopSet(leftPad) && hitSlopSet(rightPad))) { - "Cannot have all of left, right and width defined" - } - require(!(hitSlopSet(width) && !hitSlopSet(leftPad) && !hitSlopSet(rightPad))) { - "When width is set one of left or right pads need to be defined" - } - require(!(hitSlopSet(height) && hitSlopSet(bottomPad) && hitSlopSet(topPad))) { - "Cannot have all of top, bottom and height defined" - } - require(!(hitSlopSet(height) && !hitSlopSet(bottomPad) && !hitSlopSet(topPad))) { - "When height is set one of top or bottom pads need to be defined" - } } fun setHitSlop(padding: Float?) { @@ -983,68 +970,37 @@ open class GestureHandler { private const val KEY_MANUAL_ACTIVATION = "manualActivation" private const val KEY_MOUSE_BUTTON = "mouseButton" private const val KEY_HIT_SLOP = "hitSlop" - private const val KEY_HIT_SLOP_LEFT = "left" - private const val KEY_HIT_SLOP_TOP = "top" - private const val KEY_HIT_SLOP_RIGHT = "right" - private const val KEY_HIT_SLOP_BOTTOM = "bottom" - private const val KEY_HIT_SLOP_VERTICAL = "vertical" - private const val KEY_HIT_SLOP_HORIZONTAL = "horizontal" - private const val KEY_HIT_SLOP_WIDTH = "width" - private const val KEY_HIT_SLOP_HEIGHT = "height" private const val KEY_TEST_ID = "testID" private const val KEY_CANCELS_JS_RESPONDER = "cancelsJSResponder" + /** + * `hitSlop` arrives already normalized by the JS side as + * `[left, top, right, bottom, width, height]`, where `null` marks an edge that was not + * specified. Validation of the `width`/`height` combinations happens in JS as well, so all + * that is left here is converting the values from DIP to pixels. + */ private fun handleHitSlopProperty(handler: GestureHandler, config: ReadableMap) { if (config.isNull(KEY_HIT_SLOP)) { handler.setHitSlop(null) - } else if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) { - val hitSlop = PixelUtil.toPixelFromDIP(config.getDouble(KEY_HIT_SLOP)) - handler.setHitSlop( - hitSlop, - hitSlop, - hitSlop, - hitSlop, - GestureHandler.HIT_SLOP_NONE, - GestureHandler.HIT_SLOP_NONE, - ) + return + } + + val hitSlop = config.getArray(KEY_HIT_SLOP)!! + + fun edge(index: Int) = if (hitSlop.isNull(index)) { + GestureHandler.HIT_SLOP_NONE } else { - val hitSlop = config.getMap(KEY_HIT_SLOP)!! - var left = GestureHandler.HIT_SLOP_NONE - var top = GestureHandler.HIT_SLOP_NONE - var right = GestureHandler.HIT_SLOP_NONE - var bottom = GestureHandler.HIT_SLOP_NONE - var width = GestureHandler.HIT_SLOP_NONE - var height = GestureHandler.HIT_SLOP_NONE - if (hitSlop.hasKey(KEY_HIT_SLOP_HORIZONTAL)) { - val horizontalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HORIZONTAL)) - right = horizontalPad - left = right - } - if (hitSlop.hasKey(KEY_HIT_SLOP_VERTICAL)) { - val verticalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_VERTICAL)) - bottom = verticalPad - top = bottom - } - if (hitSlop.hasKey(KEY_HIT_SLOP_LEFT)) { - left = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_LEFT)) - } - if (hitSlop.hasKey(KEY_HIT_SLOP_TOP)) { - top = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_TOP)) - } - if (hitSlop.hasKey(KEY_HIT_SLOP_RIGHT)) { - right = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_RIGHT)) - } - if (hitSlop.hasKey(KEY_HIT_SLOP_BOTTOM)) { - bottom = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_BOTTOM)) - } - if (hitSlop.hasKey(KEY_HIT_SLOP_WIDTH)) { - width = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_WIDTH)) - } - if (hitSlop.hasKey(KEY_HIT_SLOP_HEIGHT)) { - height = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HEIGHT)) - } - handler.setHitSlop(left, top, right, bottom, width, height) + PixelUtil.toPixelFromDIP(hitSlop.getDouble(index)) } + + handler.setHitSlop( + edge(HIT_SLOP_LEFT_IDX), + edge(HIT_SLOP_TOP_IDX), + edge(HIT_SLOP_RIGHT_IDX), + edge(HIT_SLOP_BOTTOM_IDX), + edge(HIT_SLOP_WIDTH_IDX), + edge(HIT_SLOP_HEIGHT_IDX), + ) } } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 12e64fe14f..3065e5f148 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -33,7 +33,28 @@ - (RNGestureHandler *)gestureHandler static RNGHHitSlop RNGHHitSlopEmpty = {NAN, NAN, NAN, NAN, NAN, NAN}; -#define RNGH_HIT_SLOP_GET(key) (prop[key] == nil ? NAN : [prop[key] doubleValue]) +// `hitSlop` reaches the native side already normalized by JS into +// `[left, top, right, bottom, width, height]`, where `null` marks an unspecified edge. +typedef NS_ENUM(NSUInteger, RNGHHitSlopIndex) { + RNGHHitSlopIndexLeft = 0, + RNGHHitSlopIndexTop, + RNGHHitSlopIndexRight, + RNGHHitSlopIndexBottom, + RNGHHitSlopIndexWidth, + RNGHHitSlopIndexHeight, + RNGHHitSlopIndexCount, +}; + +static CGFloat RNGHHitSlopEdge(NSArray *hitSlop, RNGHHitSlopIndex index) +{ + if (index >= hitSlop.count) { + return NAN; + } + + id value = hitSlop[index]; + return [value isKindOfClass:[NSNumber class]] ? [value doubleValue] : NAN; +} + #define RNGH_HIT_SLOP_IS_SET(hitSlop) \ (!isnan(hitSlop.left) || !isnan(hitSlop.right) || !isnan(hitSlop.top) || !isnan(hitSlop.bottom)) #define RNGH_HIT_SLOP_INSET(key) (isnan(hitSlop.key) ? 0. : hitSlop.key) @@ -166,32 +187,18 @@ - (void)updateConfig:(NSDictionary *)config _cancelsJSResponder = [RCTConvert BOOL:prop]; } + // The `width`/`height` combinations are validated on the JS side, before the value gets here. prop = config[@"hitSlop"]; - if ([prop isKindOfClass:[NSNumber class]]) { - _hitSlop.left = _hitSlop.right = _hitSlop.top = _hitSlop.bottom = [prop doubleValue]; - } else if ([prop isKindOfClass:[NSNull class]]) { - _hitSlop = RNGHHitSlopEmpty; + if ([prop isKindOfClass:[NSArray class]]) { + _hitSlop.left = RNGHHitSlopEdge(prop, RNGHHitSlopIndexLeft); + _hitSlop.top = RNGHHitSlopEdge(prop, RNGHHitSlopIndexTop); + _hitSlop.right = RNGHHitSlopEdge(prop, RNGHHitSlopIndexRight); + _hitSlop.bottom = RNGHHitSlopEdge(prop, RNGHHitSlopIndexBottom); + _hitSlop.width = RNGHHitSlopEdge(prop, RNGHHitSlopIndexWidth); + _hitSlop.height = RNGHHitSlopEdge(prop, RNGHHitSlopIndexHeight); } else if (prop != nil) { - _hitSlop.left = _hitSlop.right = RNGH_HIT_SLOP_GET(@"horizontal"); - _hitSlop.top = _hitSlop.bottom = RNGH_HIT_SLOP_GET(@"vertical"); - _hitSlop.left = RNGH_HIT_SLOP_GET(@"left"); - _hitSlop.right = RNGH_HIT_SLOP_GET(@"right"); - _hitSlop.top = RNGH_HIT_SLOP_GET(@"top"); - _hitSlop.bottom = RNGH_HIT_SLOP_GET(@"bottom"); - _hitSlop.width = RNGH_HIT_SLOP_GET(@"width"); - _hitSlop.height = RNGH_HIT_SLOP_GET(@"height"); - if (isnan(_hitSlop.left) && isnan(_hitSlop.right) && !isnan(_hitSlop.width)) { - RCTLogError(@"When width is set one of left or right pads need to be defined"); - } - if (!isnan(_hitSlop.width) && !isnan(_hitSlop.left) && !isnan(_hitSlop.right)) { - RCTLogError(@"Cannot have all of left, right and width defined"); - } - if (isnan(_hitSlop.top) && isnan(_hitSlop.bottom) && !isnan(_hitSlop.height)) { - RCTLogError(@"When height is set one of top or bottom pads need to be defined"); - } - if (!isnan(_hitSlop.height) && !isnan(_hitSlop.top) && !isnan(_hitSlop.bottom)) { - RCTLogError(@"Cannot have all of top, bottom and height defined"); - } + // An explicit `null` clears the hit slop; a missing key leaves the previous value alone. + _hitSlop = RNGHHitSlopEmpty; } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 1c1b8c48f2..f0e5347609 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -290,12 +290,16 @@ - (NSDictionary *)buildManagedHandlerConfig:(const RNGestureHandlerButtonProps & // and the handler keeps its unset default instead of hit-testing against an identical frame. if (props.gestureHitSlop.top != 0 || props.gestureHitSlop.left != 0 || props.gestureHitSlop.bottom != 0 || props.gestureHitSlop.right != 0) { - config[@"hitSlop"] = @{ - @"top" : @(props.gestureHitSlop.top), - @"left" : @(props.gestureHitSlop.left), - @"bottom" : @(props.gestureHitSlop.bottom), - @"right" : @(props.gestureHitSlop.right), - }; + // Matches the normalized `[left, top, right, bottom, width, height]` layout the JS side sends; + // the button only exposes the four edges, so width and height are always unset. + config[@"hitSlop"] = @[ + @(props.gestureHitSlop.left), + @(props.gestureHitSlop.top), + @(props.gestureHitSlop.right), + @(props.gestureHitSlop.bottom), + [NSNull null], + [NSNull null], + ]; } return config; diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts new file mode 100644 index 0000000000..fc67d74d7d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts @@ -0,0 +1,129 @@ +import type { HitSlop } from '../handlers/gestureHandlerCommon'; +import { normalizeHitSlop } from '../handlers/hitSlop'; + +describe('normalizeHitSlop', () => { + test('passes `undefined` and `null` through', () => { + // `undefined` keeps the property out of partial config updates, `null` clears the hit slop. + expect(normalizeHitSlop(undefined)).toBeUndefined(); + expect(normalizeHitSlop(null)).toBeNull(); + }); + + test('expands a number onto every edge', () => { + expect(normalizeHitSlop(-10)).toEqual([-10, -10, -10, -10, null, null]); + expect(normalizeHitSlop(0)).toEqual([0, 0, 0, 0, null, null]); + }); + + test('marks unspecified edges as `null`', () => { + expect(normalizeHitSlop({})).toEqual([null, null, null, null, null, null]); + expect(normalizeHitSlop({ left: -10 })).toEqual([ + -10, + null, + null, + null, + null, + null, + ]); + }); + + test('treats an explicitly `undefined` edge as unspecified', () => { + // Without this, the value would reach the platforms as `0` and shrink the view. + expect(normalizeHitSlop({ left: undefined, top: -5 })).toEqual([ + null, + -5, + null, + null, + null, + null, + ]); + }); + + test('expands `horizontal` and `vertical`', () => { + expect(normalizeHitSlop({ horizontal: -10 })).toEqual([ + -10, + null, + -10, + null, + null, + null, + ]); + expect(normalizeHitSlop({ vertical: -10 })).toEqual([ + null, + -10, + null, + -10, + null, + null, + ]); + expect(normalizeHitSlop({ horizontal: -10, vertical: -5 })).toEqual([ + -10, + -5, + -10, + -5, + null, + null, + ]); + }); + + test('lets an explicit edge win over the shorthand', () => { + expect(normalizeHitSlop({ horizontal: -10, left: -20 })).toEqual([ + -20, + null, + -10, + null, + null, + null, + ]); + expect(normalizeHitSlop({ vertical: -10, bottom: -20 })).toEqual([ + null, + -10, + null, + -20, + null, + null, + ]); + }); + + test('carries `width` and `height` through', () => { + expect(normalizeHitSlop({ left: 0, width: 20 })).toEqual([ + 0, + null, + null, + null, + 20, + null, + ]); + expect(normalizeHitSlop({ bottom: 0, height: 20 })).toEqual([ + null, + null, + null, + 0, + null, + 20, + ]); + }); + + test('rejects invalid `width` and `height` combinations', () => { + expect(() => + normalizeHitSlop({ left: 0, right: 0, width: 20 } as HitSlop) + ).toThrow("cannot have all of 'left', 'right' and 'width' defined"); + + expect(() => normalizeHitSlop({ width: 20 } as HitSlop)).toThrow( + "when 'width' is defined, either 'left' or 'right' has to be defined" + ); + + expect(() => + normalizeHitSlop({ top: 0, bottom: 0, height: 20 } as HitSlop) + ).toThrow("cannot have all of 'top', 'bottom' and 'height' defined"); + + expect(() => normalizeHitSlop({ height: 20 } as HitSlop)).toThrow( + "when 'height' is defined, either 'top' or 'bottom' has to be defined" + ); + }); + + test('counts a shorthand as defining both of its edges', () => { + // `horizontal` fills in both `left` and `right`, which conflicts with `width`. + expect(() => + normalizeHitSlop({ horizontal: -10, width: 20 } as HitSlop) + ).toThrow("cannot have all of 'left', 'right' and 'width' defined"); + }); +}); diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index d96afe3115..a61e5f538e 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -3,6 +3,7 @@ import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native'; import { View } from 'react-native'; import { ActionType } from '../ActionType'; +import { normalizeHitSlop } from '../handlers/hitSlop'; import RNGestureHandlerModule from '../RNGestureHandlerModule.web'; import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; @@ -133,7 +134,7 @@ export const ButtonComponent = ({ disallowInterruption: true, yieldsToContinuousGestures: true, testID: gestureTestID, - hitSlop: gestureHitSlop, + hitSlop: normalizeHitSlop(gestureHitSlop), hasLongPressHandler, longPressDuration, }); @@ -144,7 +145,7 @@ export const ButtonComponent = ({ disallowInterruption: true, yieldsToContinuousGestures: true, testID: gestureTestID, - hitSlop: gestureHitSlop, + hitSlop: normalizeHitSlop(gestureHitSlop), hasLongPressHandler, longPressDuration, }; diff --git a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts new file mode 100644 index 0000000000..6213877aeb --- /dev/null +++ b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts @@ -0,0 +1,120 @@ +import { tagMessage } from '../utils'; +import type { HitSlop } from './gestureHandlerCommon'; + +/** + * Canonical representation of `hitSlop`, shared by every platform: + * `[left, top, right, bottom, width, height]`, where `null` marks an edge that + * the user did not specify. + * + * The public `HitSlop` type accepts a number, `horizontal`/`vertical` + * shorthands and per-edge values; normalizing all of that here means each + * platform only ever parses these six slots. `width` and `height` cannot be + * flattened into the four edges because they are resolved against the measured + * view bounds at hit-test time, so they are carried through as-is. + */ +export type CanonicalHitSlop = [ + left: number | null, + top: number | null, + right: number | null, + bottom: number | null, + width: number | null, + height: number | null, +]; + +export const HIT_SLOP_LEFT_IDX = 0; +export const HIT_SLOP_TOP_IDX = 1; +export const HIT_SLOP_RIGHT_IDX = 2; +export const HIT_SLOP_BOTTOM_IDX = 3; +export const HIT_SLOP_WIDTH_IDX = 4; +export const HIT_SLOP_HEIGHT_IDX = 5; + +type HitSlopEdge = + | 'left' + | 'right' + | 'top' + | 'bottom' + | 'vertical' + | 'horizontal' + | 'width' + | 'height'; + +type HitSlopObject = Partial>; + +function validateHitSlop(hitSlop: CanonicalHitSlop) { + 'worklet'; + const [left, top, right, bottom, width, height] = hitSlop; + + if (width !== null && left !== null && right !== null) { + throw new Error( + tagMessage( + "HitSlop error: cannot have all of 'left', 'right' and 'width' defined" + ) + ); + } + + if (width !== null && left === null && right === null) { + throw new Error( + tagMessage( + "HitSlop error: when 'width' is defined, either 'left' or 'right' has to be defined" + ) + ); + } + + if (height !== null && top !== null && bottom !== null) { + throw new Error( + tagMessage( + "HitSlop error: cannot have all of 'top', 'bottom' and 'height' defined" + ) + ); + } + + if (height !== null && top === null && bottom === null) { + throw new Error( + tagMessage( + "HitSlop error: when 'height' is defined, either 'top' or 'bottom' has to be defined" + ) + ); + } +} + +/** + * Converts the user-facing `hitSlop` into `CanonicalHitSlop`. + * + * `undefined` is passed through so that the property stays out of partial + * config updates (the platforms leave the previous value alone when the key is + * missing), while an explicit `null` is passed through as a request to clear + * the hit slop. + * + * Runs on the UI thread as well, since `hitSlop` can be a shared value. + */ +export function normalizeHitSlop( + hitSlop: HitSlop +): CanonicalHitSlop | null | undefined { + 'worklet'; + + if (hitSlop === undefined || hitSlop === null) { + return hitSlop; + } + + if (typeof hitSlop === 'number') { + return [hitSlop, hitSlop, hitSlop, hitSlop, null, null]; + } + + const slop = hitSlop as HitSlopObject; + const { horizontal, vertical } = slop; + + const normalized: CanonicalHitSlop = [ + slop.left ?? horizontal ?? null, + slop.top ?? vertical ?? null, + slop.right ?? horizontal ?? null, + slop.bottom ?? vertical ?? null, + slop.width ?? null, + slop.height ?? null, + ]; + + if (__DEV__) { + validateHitSlop(normalized); + } + + return normalized; +} diff --git a/packages/react-native-gesture-handler/src/handlers/utils.ts b/packages/react-native-gesture-handler/src/handlers/utils.ts index 499a1e1063..e69ba16778 100644 --- a/packages/react-native-gesture-handler/src/handlers/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/utils.ts @@ -4,7 +4,9 @@ import { findNodeHandle as findNodeHandleRN, Platform } from 'react-native'; import { ghQueueMicrotask } from '../ghQueueMicrotask'; import RNGestureHandlerModule from '../RNGestureHandlerModule'; import { toArray } from '../utils'; +import type { HitSlop } from './gestureHandlerCommon'; import { handlerIDToTag } from './handlersRegistry'; +import { normalizeHitSlop } from './hitSlop'; function isConfigParam(param: unknown, name: string) { // param !== Object(param) returns false if `param` is a function @@ -34,8 +36,8 @@ export function filterConfig( if (isConfigParam(value, key)) { if (key === 'simultaneousHandlers' || key === 'waitFor') { value = transformIntoHandlerTags(props[key]); - } else if (key === 'hitSlop' && typeof value !== 'object') { - value = { top: value, left: value, bottom: value, right: value }; + } else if (key === 'hitSlop') { + value = normalizeHitSlop(value as HitSlop); } filteredConfig[key] = value; } diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts index bd9bd16a6e..8d72c410ec 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts @@ -1,6 +1,8 @@ import { useMemo } from 'react'; +import type { HitSlop } from '../../../handlers/gestureHandlerCommon'; import { Reanimated } from '../../../handlers/gestures/reanimatedWrapper'; +import { normalizeHitSlop } from '../../../handlers/hitSlop'; import { isTestEnv, tagMessage } from '../../../utils'; import type { BaseGestureConfig, @@ -105,8 +107,14 @@ export function prepareConfigForNativeSide< for (const [key, value] of Object.entries(config)) { // @ts-ignore That's the point, we want to see if key exists in the whitelists if (allowedNativeProps.has(key) || handlerPropsWhiteList.has(key)) { + const unpackedValue = Reanimated?.isSharedValue(value) + ? value.value + : value; + (filteredConfig as Record)[key] = - Reanimated?.isSharedValue(value) ? value.value : value; + key === 'hitSlop' + ? normalizeHitSlop(unpackedValue as HitSlop) + : unpackedValue; } else if (PropsToFilter.has(key)) { continue; } else { diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts index 1d08ee0f1d..5d56ce3956 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts @@ -1,4 +1,6 @@ +import type { HitSlop } from '../../../handlers/gestureHandlerCommon'; import { Reanimated } from '../../../handlers/gestures/reanimatedWrapper'; +import { normalizeHitSlop } from '../../../handlers/hitSlop'; import { NativeProxy } from '../../NativeProxy'; import type { BaseGestureConfig, @@ -48,14 +50,17 @@ export function bindSharedValues< const listenerId = baseListenerId + keyHash; sharedValue.addListener(listenerId, (value) => { - updateGestureHandlerConfig( - handlerTag, - configKey === 'runOnJS' - ? { - dispatchesReanimatedEvents: shouldUseReanimatedDetector && !value, - } - : { [configKey]: value } - ); + if (configKey === 'runOnJS') { + updateGestureHandlerConfig(handlerTag, { + dispatchesReanimatedEvents: shouldUseReanimatedDetector && !value, + }); + return; + } + + updateGestureHandlerConfig(handlerTag, { + [configKey]: + configKey === 'hitSlop' ? normalizeHitSlop(value as HitSlop) : value, + }); }); }; diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index 816a5cea7f..b637e688fb 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -7,6 +7,7 @@ import type { UserSelect, } from '../../handlers/gestureHandlerCommon'; import { MouseButton } from '../../handlers/gestureHandlerCommon'; +import type { CanonicalHitSlop } from '../../handlers/hitSlop'; import { PointerType } from '../../PointerType'; import { State } from '../../State'; import { TouchEventType } from '../../TouchEventType'; @@ -21,7 +22,6 @@ import type { AdaptedEvent, Config, GestureHandlerNativeEvent, - HitSlop, HostDetector, PointerData, PropsRef, @@ -54,7 +54,7 @@ export default abstract class GestureHandler implements IGestureHandler { private _handlerTag!: number; private _testID?: string | undefined = undefined; - private hitSlop?: HitSlop | undefined = undefined; + private hitSlop?: CanonicalHitSlop | undefined = undefined; private manualActivation: boolean = false; private mouseButton?: MouseButton | undefined = undefined; private needsPointerData: boolean = false; @@ -795,7 +795,6 @@ export default abstract class GestureHandler implements IGestureHandler { // `undefined` means the property was not part of this update. if (config.hitSlop !== undefined) { this.hitSlop = config.hitSlop ?? undefined; - this.validateHitSlops(); } if (config.testID !== undefined) { @@ -870,52 +869,6 @@ export default abstract class GestureHandler implements IGestureHandler { } } - private validateHitSlops(): void { - if (!this.hitSlop) { - return; - } - - if ( - this.hitSlop.left !== undefined && - this.hitSlop.right !== undefined && - this.hitSlop.width !== undefined - ) { - throw new Error( - 'HitSlop Error: Cannot define left, right and width at the same time' - ); - } - - if ( - this.hitSlop.width !== undefined && - this.hitSlop.left === undefined && - this.hitSlop.right === undefined - ) { - throw new Error( - 'HitSlop Error: When width is defined, either left or right has to be defined' - ); - } - - if ( - this.hitSlop.height !== undefined && - this.hitSlop.top !== undefined && - this.hitSlop.bottom !== undefined - ) { - throw new Error( - 'HitSlop Error: Cannot define top, bottom and height at the same time' - ); - } - - if ( - this.hitSlop.height !== undefined && - this.hitSlop.top === undefined && - this.hitSlop.bottom === undefined - ) { - throw new Error( - 'HitSlop Error: When height is defined, either top or bottom has to be defined' - ); - } - } - private checkHitSlop(): boolean { if (!this.hitSlop) { return true; @@ -923,50 +876,43 @@ export default abstract class GestureHandler implements IGestureHandler { const { width, height } = this.delegate.measureView(); + const [slopLeft, slopTop, slopRight, slopBottom, slopWidth, slopHeight] = + this.hitSlop; + let left = 0; let top = 0; let right: number = width; let bottom: number = height; - if (this.hitSlop.horizontal !== undefined) { - left -= this.hitSlop.horizontal; - right += this.hitSlop.horizontal; - } - - if (this.hitSlop.vertical !== undefined) { - top -= this.hitSlop.vertical; - bottom += this.hitSlop.vertical; - } - - if (this.hitSlop.left !== undefined) { - left = -this.hitSlop.left; + if (slopLeft !== null) { + left = -slopLeft; } - if (this.hitSlop.right !== undefined) { - right = width + this.hitSlop.right; + if (slopRight !== null) { + right = width + slopRight; } - if (this.hitSlop.top !== undefined) { - top = -this.hitSlop.top; + if (slopTop !== null) { + top = -slopTop; } - if (this.hitSlop.bottom !== undefined) { - bottom = height + this.hitSlop.bottom; + if (slopBottom !== null) { + bottom = height + slopBottom; } - if (this.hitSlop.width !== undefined) { - if (this.hitSlop.left !== undefined) { - right = left + this.hitSlop.width; - } else if (this.hitSlop.right !== undefined) { - left = right - this.hitSlop.width; + if (slopWidth !== null) { + if (slopLeft !== null) { + right = left + slopWidth; + } else if (slopRight !== null) { + left = right - slopWidth; } } - if (this.hitSlop.height !== undefined) { - if (this.hitSlop.top !== undefined) { - bottom = top + this.hitSlop.height; - } else if (this.hitSlop.bottom !== undefined) { - top = bottom - this.hitSlop.height; + if (slopHeight !== null) { + if (slopTop !== null) { + bottom = top + slopHeight; + } else if (slopBottom !== null) { + top = bottom - slopHeight; } } diff --git a/packages/react-native-gesture-handler/src/web/interfaces.ts b/packages/react-native-gesture-handler/src/web/interfaces.ts index e276bea844..d4c314189e 100644 --- a/packages/react-native-gesture-handler/src/web/interfaces.ts +++ b/packages/react-native-gesture-handler/src/web/interfaces.ts @@ -9,6 +9,7 @@ import type { TouchAction, UserSelect, } from '../handlers/gestureHandlerCommon'; +import type { CanonicalHitSlop } from '../handlers/hitSlop'; import type { PointerType } from '../PointerType'; import type { State } from '../State'; import type { @@ -16,17 +17,6 @@ import type { GestureUpdateEventWithHandlerData, } from '../v3/types'; -export interface HitSlop { - left?: number | undefined; - right?: number | undefined; - top?: number | undefined; - bottom?: number | undefined; - horizontal?: number | undefined; - vertical?: number | undefined; - width?: number | undefined; - height?: number | undefined; -} - export interface Handler { handlerTag: number; } @@ -35,7 +25,7 @@ type ConfigArgs = | number | boolean | string - | HitSlop + | CanonicalHitSlop | UserSelect | TouchAction | ActiveCursor @@ -49,7 +39,7 @@ export interface Config extends Record { simultaneousHandlers?: Handler[] | null | undefined; waitFor?: Handler[] | null | undefined; blocksHandlers?: Handler[] | null | undefined; - hitSlop?: HitSlop | null | undefined; + hitSlop?: CanonicalHitSlop | null | undefined; shouldCancelWhenOutside?: boolean | undefined; userSelect?: UserSelect | undefined; activeCursor?: ActiveCursor | undefined; From ab910444f32c422db635bfb4d67dc3b75eeba405 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 5 Aug 2026 16:02:34 +0200 Subject: [PATCH 2/7] make normalizeHitSlop idempotent --- .../src/__tests__/hitSlop.test.ts | 22 +++++++++++++++++++ .../src/handlers/hitSlop.ts | 10 ++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts index fc67d74d7d..fb0a667950 100644 --- a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts @@ -8,6 +8,28 @@ describe('normalizeHitSlop', () => { expect(normalizeHitSlop(null)).toBeNull(); }); + test('is idempotent', () => { + // Normalizing an already normalized value must not empty it out. + const normalized = normalizeHitSlop({ horizontal: -10, top: -5 }); + + expect(normalizeHitSlop(normalized)).toEqual([ + -10, + -5, + -10, + null, + null, + null, + ]); + expect(normalizeHitSlop(normalizeHitSlop(-10))).toEqual([ + -10, + -10, + -10, + -10, + null, + null, + ]); + }); + test('expands a number onto every edge', () => { expect(normalizeHitSlop(-10)).toEqual([-10, -10, -10, -10, null, null]); expect(normalizeHitSlop(0)).toEqual([0, 0, 0, 0, null, null]); diff --git a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts index 6213877aeb..7c80fc53ba 100644 --- a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts +++ b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts @@ -85,10 +85,14 @@ function validateHitSlop(hitSlop: CanonicalHitSlop) { * missing), while an explicit `null` is passed through as a request to clear * the hit slop. * + * Already normalized values are returned as-is, which keeps the function + * idempotent — normalizing twice would otherwise find none of the edge keys on + * the array and silently empty the hit slop. + * * Runs on the UI thread as well, since `hitSlop` can be a shared value. */ export function normalizeHitSlop( - hitSlop: HitSlop + hitSlop: HitSlop | CanonicalHitSlop ): CanonicalHitSlop | null | undefined { 'worklet'; @@ -96,6 +100,10 @@ export function normalizeHitSlop( return hitSlop; } + if (Array.isArray(hitSlop)) { + return hitSlop; + } + if (typeof hitSlop === 'number') { return [hitSlop, hitSlop, hitSlop, hitSlop, null, null]; } From 17eb025bd256b2be6ec365d1b6589ae025caa2e6 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 5 Aug 2026 16:08:36 +0200 Subject: [PATCH 3/7] reject negative sizes --- .../src/__tests__/hitSlop.test.ts | 23 +++++++++++++++++++ .../src/handlers/hitSlop.ts | 11 +++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts index fb0a667950..933600801e 100644 --- a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts @@ -142,6 +142,29 @@ describe('normalizeHitSlop', () => { ); }); + test('rejects a negative `width` or `height`', () => { + expect(() => normalizeHitSlop({ left: 0, width: -20 } as HitSlop)).toThrow( + "'width' cannot be negative" + ); + + expect(() => normalizeHitSlop({ top: 0, height: -20 } as HitSlop)).toThrow( + "'height' cannot be negative" + ); + }); + + test('allows a zero `width` or `height`', () => { + // An empty hit area is degenerate but coherent, and a hit slop animated + // from zero upwards passes through it. + expect(normalizeHitSlop({ left: 0, width: 0 })).toEqual([ + 0, + null, + null, + null, + 0, + null, + ]); + }); + test('counts a shorthand as defining both of its edges', () => { // `horizontal` fills in both `left` and `right`, which conflicts with `width`. expect(() => diff --git a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts index 7c80fc53ba..82c7bb640f 100644 --- a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts +++ b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts @@ -44,6 +44,17 @@ function validateHitSlop(hitSlop: CanonicalHitSlop) { 'worklet'; const [left, top, right, bottom, width, height] = hitSlop; + // Unlike the edges, `width` and `height` are absolute sizes rather than + // deltas, so a negative value describes an inverted region that no pointer + // can fall into — the gesture would just never activate. + if (width !== null && width < 0) { + throw new Error(tagMessage("HitSlop error: 'width' cannot be negative")); + } + + if (height !== null && height < 0) { + throw new Error(tagMessage("HitSlop error: 'height' cannot be negative")); + } + if (width !== null && left !== null && right !== null) { throw new Error( tagMessage( From 22b37f3e9fe1f4711db8070a234c933720e46b0f Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 5 Aug 2026 16:34:57 +0200 Subject: [PATCH 4/7] test on native side wiring --- .../src/__tests__/hitSlopSharedValue.test.ts | 91 +++++++++++++++++++ .../src/__tests__/hitSlopWiring.test.ts | 54 +++++++++++ 2 files changed, 145 insertions(+) create mode 100644 packages/react-native-gesture-handler/src/__tests__/hitSlopSharedValue.test.ts create mode 100644 packages/react-native-gesture-handler/src/__tests__/hitSlopWiring.test.ts diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlopSharedValue.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlopSharedValue.test.ts new file mode 100644 index 0000000000..8f7d67e46e --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlopSharedValue.test.ts @@ -0,0 +1,91 @@ +import { bindSharedValues } from '../v3/hooks/utils/reanimatedUtils'; +import type { BaseGestureConfig, SharedValue } from '../v3/types'; + +const mockUpdateGestureHandlerConfig = jest.fn(); + +// `bindSharedValues` pushes updates straight to the native side from the UI +// thread, bypassing `prepareConfigForNativeSide` entirely. It is the one +// producer that is easy to forget when touching the config pipeline, so it gets +// its own test with the surrounding modules stubbed out. +jest.mock('../v3/NativeProxy', () => ({ + NativeProxy: { + // Forwarded lazily — the mocked module is required (imports and `jest.mock` + // calls are both hoisted) before the `jest.fn()` has been initialized. + updateGestureHandlerConfig: (...args: unknown[]) => + mockUpdateGestureHandlerConfig(...args), + }, +})); + +jest.mock('../handlers/gestures/reanimatedWrapper', () => ({ + Reanimated: { + isSharedValue: (value: unknown) => + typeof value === 'object' && + value !== null && + '__isFakeSharedValue' in value, + runOnUI: + (fn: (...args: TArgs) => void) => + (...args: TArgs): void => + fn(...args), + }, +})); + +type Listener = (value: unknown) => void; + +function fakeSharedValue(value: unknown) { + const listeners = new Map(); + + return { + __isFakeSharedValue: true, + value, + addListener: (id: number, listener: Listener) => + listeners.set(id, listener), + removeListener: (id: number) => listeners.delete(id), + emit: (next: unknown) => listeners.forEach((listener) => listener(next)), + }; +} + +const bind = (config: object) => + bindSharedValues( + config as BaseGestureConfig, + // Arbitrary handler tag. + 7 + ); + +describe('bindSharedValues', () => { + beforeEach(() => { + mockUpdateGestureHandlerConfig.mockClear(); + }); + + test('normalizes a hitSlop pushed from the UI thread', () => { + const hitSlop = fakeSharedValue(-10); + bind({ hitSlop: hitSlop as unknown as SharedValue }); + + hitSlop.emit({ horizontal: -10, top: -5 }); + + expect(mockUpdateGestureHandlerConfig).toHaveBeenCalledWith(7, { + hitSlop: [-10, -5, -10, null, null, null], + }); + }); + + test('passes an explicitly null hitSlop through', () => { + const hitSlop = fakeSharedValue(-10); + bind({ hitSlop: hitSlop as unknown as SharedValue }); + + hitSlop.emit(null); + + expect(mockUpdateGestureHandlerConfig).toHaveBeenCalledWith(7, { + hitSlop: null, + }); + }); + + test('leaves other config values untouched', () => { + const enabled = fakeSharedValue(true); + bind({ enabled: enabled as unknown as SharedValue }); + + enabled.emit(false); + + expect(mockUpdateGestureHandlerConfig).toHaveBeenCalledWith(7, { + enabled: false, + }); + }); +}); diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlopWiring.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlopWiring.test.ts new file mode 100644 index 0000000000..bbc58957f5 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlopWiring.test.ts @@ -0,0 +1,54 @@ +import { filterConfig } from '../handlers/utils'; +import { prepareConfigForNativeSide } from '../v3/hooks/utils/configUtils'; +import { SingleGestureName } from '../v3/types'; + +// `hitSlop` reaches the platforms through more than one producer, and every one +// of them has to emit the same normalized array — the Android, Apple and web +// parsers only understand that shape. These tests lock the wire contract at +// each producer, so a change to one of them cannot quietly break a platform. +describe('hitSlop wiring', () => { + describe('filterConfig (v1 and v2)', () => { + test('normalizes hitSlop', () => { + expect(filterConfig({ hitSlop: -10 }, ['hitSlop'])).toEqual({ + hitSlop: [-10, -10, -10, -10, null, null], + }); + + expect( + filterConfig({ hitSlop: { horizontal: -10 } }, ['hitSlop']) + ).toEqual({ hitSlop: [-10, null, -10, null, null, null] }); + }); + + test('keeps the difference between an absent and an explicitly null hitSlop', () => { + // `null` clears the hit slop, a missing key leaves the previous value alone. + expect(filterConfig({ hitSlop: null }, ['hitSlop'])).toEqual({ + hitSlop: null, + }); + + expect(filterConfig({}, ['hitSlop'])).toEqual({}); + expect(filterConfig({ hitSlop: undefined }, ['hitSlop'])).toEqual({}); + }); + }); + + describe('prepareConfigForNativeSide (v3)', () => { + const prepare = (hitSlop: unknown) => + prepareConfigForNativeSide(SingleGestureName.Pan, { + hitSlop, + } as Parameters[1]).hitSlop; + + test('normalizes hitSlop', () => { + expect(prepare(-10)).toEqual([-10, -10, -10, -10, null, null]); + expect(prepare({ horizontal: -10 })).toEqual([ + -10, + null, + -10, + null, + null, + null, + ]); + }); + + test('keeps an explicitly null hitSlop', () => { + expect(prepare(null)).toBeNull(); + }); + }); +}); From c19ebcd908431006ebd886f292ef860294a10e9e Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 5 Aug 2026 17:47:04 +0200 Subject: [PATCH 5/7] log error in worklet --- .../src/__tests__/hitSlop.test.ts | 48 ++++++++++++++++ .../src/handlers/hitSlop.ts | 57 +++++++++++-------- .../react-native-gesture-handler/src/utils.ts | 22 +++++++ 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts index 933600801e..b8a2ebe17f 100644 --- a/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/hitSlop.test.ts @@ -165,6 +165,54 @@ describe('normalizeHitSlop', () => { ]); }); + describe('on a Worklet runtime', () => { + // Throwing inside a shared value listener would tear it down and desync the + // shared value from the native config, so there the error is only reported + // and the value still goes through — exactly as it would in a release build. + let consoleError: jest.SpyInstance; + const initialRuntimeKind = globalThis.__RUNTIME_KIND; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + globalThis.__RUNTIME_KIND = initialRuntimeKind; + globalThis._WORKLET = undefined; + consoleError.mockRestore(); + }); + + const expectReported = () => { + expect(normalizeHitSlop({ width: 20 } as HitSlop)).toEqual([ + null, + null, + null, + null, + 20, + null, + ]); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining( + "when 'width' is defined, either 'left' or 'right' has to be defined" + ) + ); + }; + + test('reports instead of throwing', () => { + // 2 is the UI runtime. + globalThis.__RUNTIME_KIND = 2; + expectReported(); + }); + + test('falls back to the deprecated `_WORKLET` global', () => { + // Reanimated versions predating `__RUNTIME_KIND` only expose `_WORKLET`. + // @ts-expect-error Deliberately emulating a runtime without the new global. + globalThis.__RUNTIME_KIND = undefined; + globalThis._WORKLET = true; + expectReported(); + }); + }); + test('counts a shorthand as defining both of its edges', () => { // `horizontal` fills in both `left` and `right`, which conflicts with `width`. expect(() => diff --git a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts index 82c7bb640f..a300fa5bc8 100644 --- a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts +++ b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts @@ -1,4 +1,4 @@ -import { tagMessage } from '../utils'; +import { isWorkletRuntime, tagMessage } from '../utils'; import type { HitSlop } from './gestureHandlerCommon'; /** @@ -40,7 +40,7 @@ type HitSlopEdge = type HitSlopObject = Partial>; -function validateHitSlop(hitSlop: CanonicalHitSlop) { +function getHitSlopError(hitSlop: CanonicalHitSlop): string | null { 'worklet'; const [left, top, right, bottom, width, height] = hitSlop; @@ -48,44 +48,51 @@ function validateHitSlop(hitSlop: CanonicalHitSlop) { // deltas, so a negative value describes an inverted region that no pointer // can fall into — the gesture would just never activate. if (width !== null && width < 0) { - throw new Error(tagMessage("HitSlop error: 'width' cannot be negative")); + return "HitSlop error: 'width' cannot be negative"; } if (height !== null && height < 0) { - throw new Error(tagMessage("HitSlop error: 'height' cannot be negative")); + return "HitSlop error: 'height' cannot be negative"; } if (width !== null && left !== null && right !== null) { - throw new Error( - tagMessage( - "HitSlop error: cannot have all of 'left', 'right' and 'width' defined" - ) - ); + return "HitSlop error: cannot have all of 'left', 'right' and 'width' defined"; } if (width !== null && left === null && right === null) { - throw new Error( - tagMessage( - "HitSlop error: when 'width' is defined, either 'left' or 'right' has to be defined" - ) - ); + return "HitSlop error: when 'width' is defined, either 'left' or 'right' has to be defined"; } if (height !== null && top !== null && bottom !== null) { - throw new Error( - tagMessage( - "HitSlop error: cannot have all of 'top', 'bottom' and 'height' defined" - ) - ); + return "HitSlop error: cannot have all of 'top', 'bottom' and 'height' defined"; } if (height !== null && top === null && bottom === null) { - throw new Error( - tagMessage( - "HitSlop error: when 'height' is defined, either 'top' or 'bottom' has to be defined" - ) - ); + return "HitSlop error: when 'height' is defined, either 'top' or 'bottom' has to be defined"; } + + return null; +} + +function reportHitSlopError(hitSlop: CanonicalHitSlop) { + 'worklet'; + const error = getHitSlopError(hitSlop); + + if (error === null) { + return; + } + + // On the UI runtime this runs inside a shared value listener, where throwing + // would tear down the listener and leave the shared value and the native + // config out of sync. Report instead, and let the invalid value take the same + // path it would take in a release build — validation is purely diagnostic and + // must not change what the pipeline does. + if (isWorkletRuntime()) { + console.error(tagMessage(error)); + return; + } + + throw new Error(tagMessage(error)); } /** @@ -132,7 +139,7 @@ export function normalizeHitSlop( ]; if (__DEV__) { - validateHitSlop(normalized); + reportHitSlopError(normalized); } return normalized; diff --git a/packages/react-native-gesture-handler/src/utils.ts b/packages/react-native-gesture-handler/src/utils.ts index 6d715361c0..7c1ca3118d 100644 --- a/packages/react-native-gesture-handler/src/utils.ts +++ b/packages/react-native-gesture-handler/src/utils.ts @@ -43,6 +43,28 @@ export function tagMessage(msg: string) { return `[react-native-gesture-handler] ${msg}`; } +const RUNTIME_KIND_REACT_NATIVE = 1; + +/** + * Whether the caller is executing on a Worklet runtime (the UI runtime or a + * worker) rather than on the React Native one. + * + * Worklets deprecated `_WORKLET` in favour of `__RUNTIME_KIND`, but Reanimated + * is an optional dependency here and older versions only expose the former, so + * both are consulted. + */ +export function isWorkletRuntime(): boolean { + 'worklet'; + + const runtimeKind: number | undefined = globalThis.__RUNTIME_KIND; + + if (runtimeKind !== undefined) { + return runtimeKind !== RUNTIME_KIND_REACT_NATIVE; + } + + return globalThis._WORKLET === true; +} + export function isRemoteDebuggingEnabled(): boolean { // react-native-reanimated checks if in remote debugging in the same way // @ts-ignore global is available but node types are not included From 881869a9758aae8c9ad57fc55e10c02740cf1ee7 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 7 Aug 2026 18:22:08 +0200 Subject: [PATCH 6/7] fixes --- .../apple/RNGestureHandler.mm | 5 -- .../src/handlers/hitSlop.ts | 59 ++++++------------- .../react-native-gesture-handler/src/utils.ts | 22 ------- .../src/v3/hooks/utils/configUtils.ts | 4 +- .../src/v3/hooks/utils/reanimatedUtils.ts | 10 ++-- .../src/v3/types/ConfigTypes.ts | 3 +- 6 files changed, 28 insertions(+), 75 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 3065e5f148..4519de8723 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -47,10 +47,6 @@ typedef NS_ENUM(NSUInteger, RNGHHitSlopIndex) { static CGFloat RNGHHitSlopEdge(NSArray *hitSlop, RNGHHitSlopIndex index) { - if (index >= hitSlop.count) { - return NAN; - } - id value = hitSlop[index]; return [value isKindOfClass:[NSNumber class]] ? [value doubleValue] : NAN; } @@ -187,7 +183,6 @@ - (void)updateConfig:(NSDictionary *)config _cancelsJSResponder = [RCTConvert BOOL:prop]; } - // The `width`/`height` combinations are validated on the JS side, before the value gets here. prop = config[@"hitSlop"]; if ([prop isKindOfClass:[NSArray class]]) { _hitSlop.left = RNGHHitSlopEdge(prop, RNGHHitSlopIndexLeft); diff --git a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts index a300fa5bc8..2015456830 100644 --- a/packages/react-native-gesture-handler/src/handlers/hitSlop.ts +++ b/packages/react-native-gesture-handler/src/handlers/hitSlop.ts @@ -1,4 +1,3 @@ -import { isWorkletRuntime, tagMessage } from '../utils'; import type { HitSlop } from './gestureHandlerCommon'; /** @@ -40,59 +39,44 @@ type HitSlopEdge = type HitSlopObject = Partial>; -function getHitSlopError(hitSlop: CanonicalHitSlop): string | null { +function validateHitSlop(hitSlop: CanonicalHitSlop) { 'worklet'; const [left, top, right, bottom, width, height] = hitSlop; // Unlike the edges, `width` and `height` are absolute sizes rather than // deltas, so a negative value describes an inverted region that no pointer - // can fall into — the gesture would just never activate. + // can fall into. if (width !== null && width < 0) { - return "HitSlop error: 'width' cannot be negative"; + throw new Error("HitSlop error: 'width' cannot be negative"); } if (height !== null && height < 0) { - return "HitSlop error: 'height' cannot be negative"; + throw new Error("HitSlop error: 'height' cannot be negative"); } if (width !== null && left !== null && right !== null) { - return "HitSlop error: cannot have all of 'left', 'right' and 'width' defined"; + throw new Error( + "HitSlop error: cannot have all of 'left', 'right' and 'width' defined" + ); } if (width !== null && left === null && right === null) { - return "HitSlop error: when 'width' is defined, either 'left' or 'right' has to be defined"; + throw new Error( + "HitSlop error: when 'width' is defined, either 'left' or 'right' has to be defined" + ); } if (height !== null && top !== null && bottom !== null) { - return "HitSlop error: cannot have all of 'top', 'bottom' and 'height' defined"; + throw new Error( + "HitSlop error: cannot have all of 'top', 'bottom' and 'height' defined" + ); } if (height !== null && top === null && bottom === null) { - return "HitSlop error: when 'height' is defined, either 'top' or 'bottom' has to be defined"; + throw new Error( + "HitSlop error: when 'height' is defined, either 'top' or 'bottom' has to be defined" + ); } - - return null; -} - -function reportHitSlopError(hitSlop: CanonicalHitSlop) { - 'worklet'; - const error = getHitSlopError(hitSlop); - - if (error === null) { - return; - } - - // On the UI runtime this runs inside a shared value listener, where throwing - // would tear down the listener and leave the shared value and the native - // config out of sync. Report instead, and let the invalid value take the same - // path it would take in a release build — validation is purely diagnostic and - // must not change what the pipeline does. - if (isWorkletRuntime()) { - console.error(tagMessage(error)); - return; - } - - throw new Error(tagMessage(error)); } /** @@ -104,8 +88,7 @@ function reportHitSlopError(hitSlop: CanonicalHitSlop) { * the hit slop. * * Already normalized values are returned as-is, which keeps the function - * idempotent — normalizing twice would otherwise find none of the edge keys on - * the array and silently empty the hit slop. + * idempotent. * * Runs on the UI thread as well, since `hitSlop` can be a shared value. */ @@ -114,11 +97,7 @@ export function normalizeHitSlop( ): CanonicalHitSlop | null | undefined { 'worklet'; - if (hitSlop === undefined || hitSlop === null) { - return hitSlop; - } - - if (Array.isArray(hitSlop)) { + if (hitSlop === undefined || hitSlop === null || Array.isArray(hitSlop)) { return hitSlop; } @@ -139,7 +118,7 @@ export function normalizeHitSlop( ]; if (__DEV__) { - reportHitSlopError(normalized); + validateHitSlop(normalized); } return normalized; diff --git a/packages/react-native-gesture-handler/src/utils.ts b/packages/react-native-gesture-handler/src/utils.ts index 7c1ca3118d..6d715361c0 100644 --- a/packages/react-native-gesture-handler/src/utils.ts +++ b/packages/react-native-gesture-handler/src/utils.ts @@ -43,28 +43,6 @@ export function tagMessage(msg: string) { return `[react-native-gesture-handler] ${msg}`; } -const RUNTIME_KIND_REACT_NATIVE = 1; - -/** - * Whether the caller is executing on a Worklet runtime (the UI runtime or a - * worker) rather than on the React Native one. - * - * Worklets deprecated `_WORKLET` in favour of `__RUNTIME_KIND`, but Reanimated - * is an optional dependency here and older versions only expose the former, so - * both are consulted. - */ -export function isWorkletRuntime(): boolean { - 'worklet'; - - const runtimeKind: number | undefined = globalThis.__RUNTIME_KIND; - - if (runtimeKind !== undefined) { - return runtimeKind !== RUNTIME_KIND_REACT_NATIVE; - } - - return globalThis._WORKLET === true; -} - export function isRemoteDebuggingEnabled(): boolean { // react-native-reanimated checks if in remote debugging in the same way // @ts-ignore global is available but node types are not included diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts index 8d72c410ec..152aac23cf 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts @@ -107,9 +107,7 @@ export function prepareConfigForNativeSide< for (const [key, value] of Object.entries(config)) { // @ts-ignore That's the point, we want to see if key exists in the whitelists if (allowedNativeProps.has(key) || handlerPropsWhiteList.has(key)) { - const unpackedValue = Reanimated?.isSharedValue(value) - ? value.value - : value; + const unpackedValue = maybeUnpackValue(value); (filteredConfig as Record)[key] = key === 'hitSlop' diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts index 5d56ce3956..03e28af1ba 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts @@ -57,10 +57,12 @@ export function bindSharedValues< return; } - updateGestureHandlerConfig(handlerTag, { - [configKey]: - configKey === 'hitSlop' ? normalizeHitSlop(value as HitSlop) : value, - }); + if (configKey === 'hitSlop') { + updateGestureHandlerConfig(handlerTag, { + hitSlop: normalizeHitSlop(value as HitSlop), + }); + return; + } }); }; diff --git a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts index f8a4d88305..f9c935dbb4 100644 --- a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts @@ -6,6 +6,7 @@ import type { TouchAction, UserSelect, } from '../../handlers/gestureHandlerCommon'; +import type { CanonicalHitSlop } from '../../handlers/hitSlop'; import type { AnimatedEvent, ChangeCalculatorType, @@ -73,7 +74,7 @@ export type CommonGestureConfig = { runOnJS?: boolean | undefined; enabled?: boolean | undefined; shouldCancelWhenOutside?: boolean | undefined; - hitSlop?: HitSlop | undefined; + hitSlop?: HitSlop | CanonicalHitSlop | undefined; activeCursor?: ActiveCursor | undefined; mouseButton?: MouseButton | undefined; cancelsTouchesInView?: boolean | undefined; From a4efeb2e2aafd42bb8ad38bbc61be3410322cc70 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 11 Aug 2026 17:04:08 +0200 Subject: [PATCH 7/7] add fallback for updateGestureHandlerConfig --- .../src/v3/hooks/utils/reanimatedUtils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts index 03e28af1ba..deba3873eb 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts @@ -63,6 +63,8 @@ export function bindSharedValues< }); return; } + + updateGestureHandlerConfig(handlerTag, { [configKey]: value }); }); };