diff --git a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java index 69624e0ea5..cae9973aa9 100644 --- a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java +++ b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java @@ -23,6 +23,9 @@ public RNGestureHandlerRootViewManagerDelegate(U viewManager) { @Override public void setProperty(T view, String propName, @Nullable Object value) { switch (propName) { + case "pointerEvents": + mViewManager.setPointerEvents(view, (String) value); + break; case "unstable_forceActive": mViewManager.setUnstable_forceActive(view, value == null ? false : (boolean) value); break; diff --git a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java index 9864e0fc48..27a653852f 100644 --- a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java +++ b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java @@ -10,8 +10,10 @@ package com.facebook.react.viewmanagers; import android.view.View; +import androidx.annotation.Nullable; import com.facebook.react.uimanager.ViewManagerWithGeneratedInterface; public interface RNGestureHandlerRootViewManagerInterface extends ViewManagerWithGeneratedInterface { + void setPointerEvents(T view, @Nullable String value); void setUnstable_forceActive(T view, boolean value); } 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 a0caa5ae99..24237ed304 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 @@ -568,10 +568,14 @@ open class GestureHandler { } // if there are tracked pointers and the gesture is about to end, send event cancelling all pointers - if (trackedPointersCount > 0 && - (newState == STATE_END || newState == STATE_CANCELLED || newState == STATE_FAILED) - ) { + if (trackedPointersCount > 0 && isFinished(newState)) { cancelPointers() + + // `cancelPointers` dispatches the event synchronously and its callback may change the state + // (e.g. via `GestureStateManager.fail`), in which case the transition is already complete. + if (isFinished(state)) { + return + } } val oldState = state @@ -951,6 +955,8 @@ open class GestureHandler { } companion object { + fun isFinished(state: Int) = state == STATE_CANCELLED || state == STATE_FAILED || state == STATE_END + private const val DEFAULT_NEEDS_POINTER_DATA = false private const val DEFAULT_MANUAL_ACTIVATION = false private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = false diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 7d9cb45bf2..3005920481 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -97,7 +97,7 @@ class GestureHandlerOrchestrator( private fun cleanupFinishedHandlers() { for (handler in gestureHandlers.asReversed()) { - if (isFinished(handler.state) && !handler.isAwaiting) { + if (GestureHandler.isFinished(handler.state) && !handler.isAwaiting) { handler.reset() handler.apply { isActive = false @@ -107,13 +107,13 @@ class GestureHandlerOrchestrator( } } - gestureHandlers.removeAll { isFinished(it.state) && !it.isAwaiting } + gestureHandlers.removeAll { GestureHandler.isFinished(it.state) && !it.isAwaiting } finishedHandlersCleanupScheduled = false } private fun hasOtherHandlerToWaitFor(handler: GestureHandler) = - gestureHandlers.any { !isFinished(it.state) && shouldHandlerWaitForOther(handler, it) } + gestureHandlers.any { !GestureHandler.isFinished(it.state) && shouldHandlerWaitForOther(handler, it) } private fun shouldBeCancelledByFinishedHandler(handler: GestureHandler) = gestureHandlers.any { shouldHandlerWaitForOther(handler, it) && it.state == GestureHandler.STATE_END } @@ -163,7 +163,7 @@ class GestureHandlerOrchestrator( handler.isAwaiting = false } - if (isFinished(newState)) { + if (GestureHandler.isFinished(newState)) { // We have to loop through copy in order to avoid modifying collection // while iterating over its elements val currentlyAwaitingHandlers = awaitingHandlers.toList() @@ -678,6 +678,7 @@ class GestureHandlerOrchestrator( continue } view.onTouchEvent(cancelEvent) + view.stopNestedScroll() } cancelEvent.recycle() @@ -927,9 +928,5 @@ class GestureHandlerOrchestrator( true } } - - private fun isFinished(state: Int) = state == GestureHandler.STATE_CANCELLED || - state == GestureHandler.STATE_FAILED || - state == GestureHandler.STATE_END } } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt index 8a6b6ec75e..83012af472 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt @@ -124,6 +124,7 @@ class HoverGestureHandler : GestureHandler() { override fun onReset() { super.onReset() stylusData = StylusData() + handler?.removeCallbacksAndMessages(null) } private fun finish() { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index edbdff9b19..a155595d4b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -155,7 +155,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } companion object { - private const val MIN_ALPHA_FOR_TOUCH = 0.1f + // Same threshold as UIKit / RCTViewComponentView hit testing on iOS. + private const val MIN_ALPHA_FOR_TOUCH = 0.01f private fun findRootViewTag(viewGroup: ViewGroup): ViewGroup { UiThreadUtil.assertOnUiThread() var parent: ViewParent? = viewGroup diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt index f209a7c407..8f5de396da 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt @@ -1,6 +1,7 @@ package com.swmansion.gesturehandler.react import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.PointerEvents.Companion.parsePointerEvents import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate @@ -33,6 +34,11 @@ class RNGestureHandlerRootViewManager : view.tearDown() } + @ReactProp(name = "pointerEvents") + override fun setPointerEvents(view: RNGestureHandlerRootView, pointerEventsStr: String?) { + view.pointerEvents = parsePointerEvents(pointerEventsStr) + } + @ReactProp(name = "unstable_forceActive") override fun setUnstable_forceActive(view: RNGestureHandlerRootView, active: Boolean) { view.setUnstableForceActive(active) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index ea38a8334b..58c3fccd99 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -105,7 +105,7 @@ - (instancetype)initWithTag:(NSNumber *)tag - (void)resetConfig { self.enabled = YES; - self.manualActivation = NO; + _manualActivation = NO; _shouldCancelWhenOutside = NO; _handlersToWaitFor = nil; _simultaneousHandlers = nil; @@ -176,6 +176,8 @@ - (void)configure:(NSDictionary *)config RCTLogError(@"Cannot have all of top, bottom and height defined"); } } + + [self syncManualActivationRecognizer]; } - (void)setEnabled:(BOOL)enabled @@ -480,14 +482,24 @@ - (void)stopActivationBlocker - (void)setManualActivation:(BOOL)manualActivation { _manualActivation = manualActivation; + [self syncManualActivationRecognizer]; +} + +- (void)syncManualActivationRecognizer +{ + BOOL hasRecognizer = _manualActivationRecognizer != nil; - if (manualActivation) { + if (hasRecognizer == _manualActivation) { + return; + } + + if (_manualActivation) { _manualActivationRecognizer = [[RNManualActivationRecognizer alloc] initWithGestureHandler:self]; if (_recognizer.view != nil) { [_recognizer.view addGestureRecognizer:_manualActivationRecognizer]; } - } else if (_manualActivationRecognizer != nil) { + } else { [_manualActivationRecognizer.view removeGestureRecognizer:_manualActivationRecognizer]; _manualActivationRecognizer = nil; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index fda94c4bef..471e6730e9 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -259,8 +259,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // This is necessary because RCTViewComponentView's hitTest might handle pointerEvents // from ViewProps and prevent touches from reaching _buttonView (which is the contentView). // Since _buttonView has its own pointerEvents handling, we always forward to it. +// Keep the visibility checks RCTViewComponentView applies, so a hidden or +// (nearly) transparent button is skipped like any other RN view. - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { + if (!self.userInteractionEnabled || self.hidden || self.alpha < 0.01) { + return nil; + } + if (![self pointInside:point withEvent:event]) { return nil; } diff --git a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx index 9bbd74484d..c6a60e5ac3 100644 --- a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx @@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react-native'; import React from 'react'; import { findNodeHandle, View } from 'react-native'; +import { Wrap } from '../handlers/gestures/GestureDetector/Wrap'; +import * as reanimatedWrapper from '../handlers/gestures/reanimatedWrapper'; import { Gesture, GestureDetector, GestureHandlerRootView } from '../index'; import RNGestureHandlerModule from '../RNGestureHandlerModule'; @@ -132,6 +134,65 @@ describe('Legacy GestureDetector ref forwarding', () => { expect(secondRef).toHaveBeenCalledWith(childInstance); }); + describe('animatable ref for Reanimated', () => { + let refreshSpy: jest.SpyInstance; + + beforeEach(() => { + refreshSpy = jest.spyOn( + reanimatedWrapper, + 'reanimatedRefreshesAnimatableRef' + ); + }); + + afterEach(() => { + refreshSpy.mockRestore(); + }); + + test('exposes the child host instance when Reanimated re-resolves it on update', () => { + refreshSpy.mockReturnValue(true); + const wrapRef = React.createRef(); + const childRef = React.createRef(); + + render( + + + + ); + + expect(childRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(childRef.current); + }); + + test('falls back to itself without a host instance', () => { + refreshSpy.mockReturnValue(true); + const wrapRef = React.createRef(); + + render( + + + + ); + + expect(wrapRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current); + }); + + test('returns itself when Reanimated caches the animatable ref', () => { + refreshSpy.mockReturnValue(false); + const wrapRef = React.createRef(); + const childRef = React.createRef(); + + render( + + + + ); + + expect(childRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current); + }); + }); + test('does not reattach gestures on re-render', () => { const gesture = Gesture.Tap(); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx index 8abb581e25..29a347fab2 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx @@ -4,8 +4,12 @@ import { assignRef, isHostInstance, preferHostInstance, + resolveHostInstance, } from '../../../hostInstance'; -import { Reanimated } from '../reanimatedWrapper'; +import { + Reanimated, + reanimatedRefreshesAnimatableRef, +} from '../reanimatedWrapper'; import { tagMessage } from '../../../utils'; export class Wrap extends React.Component<{ @@ -24,6 +28,18 @@ export class Wrap extends React.Component<{ return this.hostInstance; } + // Used by Reanimated's `createAnimatedComponent` (`AnimatedWrap`). Without it, + // Reanimated calls `findHostInstance_DEPRECATED` on this class instance, which + // warns under StrictMode. Reanimated caches the result, so the child's host + // instance is only safe to hand out when it re-resolves the ref on update; + // otherwise return the instance itself, which keeps the deprecated lookup. + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public getAnimatableRef() { + return reanimatedRefreshesAnimatableRef() + ? resolveHostInstance(this) + : this; + } + private detachChildRef() { if (this.childRefCleanup !== undefined) { this.childRefCleanup(); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts index 4af2b57031..aef56ed329 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts @@ -13,6 +13,7 @@ import { extractGestureRelations, checkGestureCallbacksForWorklets, ALLOWED_PROPS, + hasExternalRelations, } from './utils'; import { MountRegistry } from '../../../mountRegistry'; @@ -53,6 +54,9 @@ export function attachHandlers({ registerHandler(handler.handlerTag, handler, handler.config.testId); } + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because all refs should be initialized // when it's ran ghQueueMicrotask(() => { @@ -70,9 +74,16 @@ export function attachHandlers({ ); } + preparedGesture.hasExternalRelations = + hasExternalRelations(gesturesToAttach); + scheduleFlushOperations(); }); + // Set before the mount events below fire, so this detector's own mount + // listener can tell its gestures apart from external ones + preparedGesture.attachedGestures = gesturesToAttach; + for (const gesture of gesturesToAttach) { const actionType = gesture.shouldUseReanimated ? ActionType.REANIMATED_WORKLET @@ -98,8 +109,6 @@ export function attachHandlers({ MountRegistry.gestureWillMount(gesture); } - preparedGesture.attachedGestures = gesturesToAttach; - if (preparedGesture.animatedHandlers) { const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated; diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 0ad930fe04..e39e930392 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -127,6 +127,10 @@ export const GestureDetector = (props: GestureDetectorProps) => { animatedHandlers: null, shouldUseReanimated: shouldUseReanimated, isMounted: false, + // The real value is computed in the attach microtask, which runs after + // this commit's effects. Until then react to every mount, so a relation + // to a gesture mounting in the same commit is never skipped. + hasExternalRelations: true, }).current; const updateAttachedGestures = useDetectorUpdater( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts index 398e55e870..9baafc11d0 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts @@ -15,6 +15,9 @@ export interface AttachedGestureState { shouldUseReanimated: boolean; // Whether the GestureDetector is mounted isMounted: boolean; + // Whether any attached gesture has a relation to a gesture outside this + // detector (a ref or a gesture object), which may resolve on a later mount + hasExternalRelations: boolean; } export interface GestureDetectorState { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts index 22906ebb89..9d7fdd68b4 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -9,6 +9,7 @@ import { extractGestureRelations, checkGestureCallbacksForWorklets, ALLOWED_PROPS, + hasExternalRelations, } from './utils'; export function updateHandlers( @@ -33,6 +34,9 @@ export function updateHandlers( // Store attached gestures to avoid crash when gestures changed after queueing micro task const attachedGestures = preparedGesture.attachedGestures; + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated // and handlerTags in BaseGesture references should be updated in the loop above (we need to wait // in case of external relations) @@ -77,6 +81,10 @@ export function updateHandlers( registerHandler(handler.handlerTag, handler, handler.config.testId); } + // Classify the new instances: their configs reference the new siblings, + // which the old attached objects would mistake for external gestures + preparedGesture.hasExternalRelations = hasExternalRelations(newGestures); + if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) { const newHandlersValue = attachedGestures .filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts index ff4628f24f..ceb78b844f 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts @@ -1,19 +1,33 @@ -import { transformIntoHandlerTags } from '../../utils'; +import type { GestureMountListener } from '../../../mountRegistry'; import { MountRegistry } from '../../../mountRegistry'; import { AttachedGestureState } from './types'; import { useEffect } from 'react'; import { GestureRef } from '../gesture'; +type MountedGesture = Parameters[0]; + function shouldUpdateDetector( relation: GestureRef[] | undefined, - gesture: { handlerTag: number } + gesture: MountedGesture ) { if (relation === undefined) { return false; } - for (const tag of transformIntoHandlerTags(relation)) { - if (tag === gesture.handlerTag) { + for (const entry of relation) { + // A gesture object gets its tag in `initialize`, when its own detector + // attaches. The mount event carries that very object, so identity is enough. + if (entry === gesture) { + return true; + } + + if (entry === null || typeof entry !== 'object' || !('current' in entry)) { + continue; + } + + const current = entry.current as { handlerTag?: number } | null | undefined; + + if (current?.handlerTag === gesture.handlerTag) { return true; } } @@ -33,6 +47,17 @@ export function useMountReactions( return; } + // Nothing here can resolve late, skip the scan (most detectors) + if (!state.hasExternalRelations) { + return; + } + + // Own gestures are resolved by the attach microtask, and on a reattach + // they would match the sibling entries added by composition + if (state.attachedGestures.includes(gesture as never)) { + return; + } + // At this point the ref in the gesture config should be updated, so we can check if one of the gestures // set in a relation with the gesture got mounted. If so, we need to update the detector to propagate // the changes to the native side. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index f09f4ec916..e73e391bcd 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -64,21 +64,53 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { ); } +// Relations that may resolve to a different handler after this detector attached: +// refs (filled when the target mounts) and gesture objects attached by another +// detector (they get a tag in `initialize`). Siblings added by composition are +// attached together with the gesture, so they never resolve late. +export function hasExternalRelations(gestures: GestureType[]) { + const ownGestures = new Set(gestures); + + for (const gesture of gestures) { + const relations = [ + gesture.config.blocksHandlers, + gesture.config.requireToFail, + gesture.config.simultaneousWith, + ]; + + for (const relation of relations) { + if (relation === undefined) { + continue; + } + + for (const entry of relation) { + if (typeof entry === 'number') { + continue; + } + + // Anything but our own gesture object is external: a ref, or a gesture + // attached by another detector + if (!(entry instanceof BaseGesture) || !ownGestures.has(entry)) { + return true; + } + } + } + } + + return false; +} + export function extractGestureRelations(gesture: GestureType) { - gesture.config.requireToFail = extractValidHandlerTags( - gesture.config.requireToFail - ); - gesture.config.simultaneousWith = extractValidHandlerTags( + const requireToFail = extractValidHandlerTags(gesture.config.requireToFail); + const simultaneousWith = extractValidHandlerTags( gesture.config.simultaneousWith ); - gesture.config.blocksHandlers = extractValidHandlerTags( - gesture.config.blocksHandlers - ); + const blocksHandlers = extractValidHandlerTags(gesture.config.blocksHandlers); return { - waitFor: gesture.config.requireToFail, - simultaneousHandlers: gesture.config.simultaneousWith, - blocksHandlers: gesture.config.blocksHandlers, + waitFor: requireToFail, + simultaneousHandlers: simultaneousWith, + blocksHandlers: blocksHandlers, }; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts index d5e4fe527e..44edea8000 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts @@ -131,6 +131,17 @@ export abstract class BaseGesture< public handlerTag = -1; public handlerName = ''; public config: BaseGestureConfig = {}; + // Snapshot of the relations defined directly on this gesture (e.g. via + // `simultaneousWithExternalGesture`), captured before any composition extends + // them. Composition rebuilds the relation config from this snapshot on every + // `prepare`, so repeated renders don't accumulate references to gestures from + // previous renders (memory leak, see #3763), while keeping the original + // references so relations stay re-resolvable after a remount, such as a + // `react-freeze` unfreeze (see #4238). + public relationsSnapshot?: { + simultaneousWith: GestureRef[] | undefined; + requireToFail: GestureRef[] | undefined; + }; public handlers: HandlerCallbacks = { gestureId: -1, handlerTag: -1, diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts index ff1096c47b..a0696ccb63 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts @@ -27,16 +27,29 @@ export class ComposedGesture extends Gesture { requireGesturesToFail: GestureType[] ) { if (gesture instanceof BaseGesture) { + // Capture the relations defined directly on the gesture before composition + // extends them, then always rebuild from that snapshot. Otherwise, when the + // gesture is stable (e.g. wrapped in `useMemo`) but the composition is + // recreated on every render, the relations would keep accumulating + // references to gestures from previous renders, leaking memory (see #3763). + // We keep the original references (instead of collapsing them to handler + // tags) so relations can still be re-resolved after a remount, such as a + // `react-freeze` unfreeze (see #4238). + gesture.relationsSnapshot ??= { + simultaneousWith: gesture.config.simultaneousWith, + requireToFail: gesture.config.requireToFail, + }; + const newConfig = { ...gesture.config }; // No need to extend `blocksHandlers` here, because it's not changed in composition. // The same effect is achieved by reversing the order of 2 gestures in `Exclusive` newConfig.simultaneousWith = extendRelation( - newConfig.simultaneousWith, + gesture.relationsSnapshot.simultaneousWith, simultaneousGestures ); newConfig.requireToFail = extendRelation( - newConfig.requireToFail, + gesture.relationsSnapshot.requireToFail, requireGesturesToFail ); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts index d4e00e9277..71e581365a 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts @@ -54,4 +54,27 @@ if (Reanimated !== undefined && !Reanimated.setGestureState) { }; } +// Reanimated resolves `getAnimatableRef()` once, when its ref to the wrapped +// component attaches. Only versions that re-resolve it on update (their +// `AnimatedComponent` has `_refreshComponentRef`) can be handed a child's host +// instance that may change, as the legacy `Wrap` does. The class is not +// exported, so probe it by subpath, as the v3 detector does for +// `NativeEventsManager`. +let refreshesAnimatableRef = false; +try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const AnimatedComponent = + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access + require('react-native-reanimated/src/createAnimatedComponent/AnimatedComponent').default; + refreshesAnimatableRef = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof AnimatedComponent?.prototype?._refreshComponentRef === 'function'; +} catch { + // Reanimated is missing or older; keep handing Reanimated the Wrap itself. +} + +export function reanimatedRefreshesAnimatableRef() { + return refreshesAnimatableRef; +} + export { Reanimated }; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts index fdfd2e220d..a3f99df953 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts @@ -1,10 +1,19 @@ import { codegenNativeComponent } from 'react-native'; -import type { HostComponent, ViewProps } from 'react-native'; +import type { CodegenTypes, HostComponent, ViewProps } from 'react-native'; export interface RootViewNativeProps extends ViewProps { unstable_forceActive?: boolean; } -export default codegenNativeComponent( +// @ts-expect-error WithDefault adds null for codegen, unlike ViewProps.pointerEvents. +interface NativeProps extends ViewProps { + pointerEvents?: CodegenTypes.WithDefault< + 'box-none' | 'none' | 'box-only' | 'auto', + 'auto' + >; + unstable_forceActive?: boolean; +} + +export default codegenNativeComponent( 'RNGestureHandlerRootView' ) as HostComponent; diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts index c545cef537..9f22c07cda 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts @@ -12,6 +12,7 @@ import { Config } from '../interfaces'; import { MouseButton } from '../../handlers/gestureHandlerCommon'; import KeyboardEventManager from './KeyboardEventManager'; import WheelEventManager from './WheelEventManager'; +import PanGestureHandler from '../handlers/PanGestureHandler'; interface DefaultViewStyles { userSelect: string; @@ -49,6 +50,7 @@ export class GestureHandlerWebDelegate }; const config = handler.config; + const shouldSendWheelEvents = handler instanceof PanGestureHandler; this.setUserSelect(config.enabled); this.setTouchAction(config.enabled); @@ -56,7 +58,10 @@ export class GestureHandlerWebDelegate this.eventManagers.push(new PointerEventManager(this.view)); this.eventManagers.push(new KeyboardEventManager(this.view)); - this.eventManagers.push(new WheelEventManager(this.view)); + + if (shouldSendWheelEvents) { + this.eventManagers.push(new WheelEventManager(this.view)); + } this.eventManagers.forEach((manager) => this.gestureHandler.attachEventManager(manager) diff --git a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts index ba7cf4cc78..4d0362ae88 100644 --- a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts @@ -3,9 +3,10 @@ import EventManager from './EventManager'; import { PointerType } from '../../PointerType'; export default class KeyboardEventManager extends EventManager { - private static activationKeys = ['Enter', ' ']; - private static cancelationKeys = ['Tab']; - private isPressed = false; + private static activationKeys = new Set(['Enter', ' ']); + private static cancelationKeys = new Set(['Tab']); + private keyPointer: string | null = null; + private static registeredStaticListeners = false; private static instances: Set = new Set(); @@ -13,8 +14,7 @@ export default class KeyboardEventManager extends EventManager { // We need a global listener, as in some cases, keyUp event gets stop-propagated. // Then, if we used only component-level listeners the gesture would never end, // causing other gestues to fail. - - if (this.activationKeys.indexOf(event.key) === -1) { + if (!this.activationKeys.has(event.key)) { return; } @@ -24,30 +24,30 @@ export default class KeyboardEventManager extends EventManager { }; private keyDownCallback = (event: KeyboardEvent): void => { - if ( - KeyboardEventManager.cancelationKeys.indexOf(event.key) !== -1 && - this.isPressed - ) { - this.dispatchEvent(event, EventTypes.CANCEL); + if (this.keyPointer !== null) { + if (KeyboardEventManager.cancelationKeys.has(event.key)) { + this.dispatchEvent(event, EventTypes.CANCEL); + this.keyPointer = null; + } + return; } - if (KeyboardEventManager.activationKeys.indexOf(event.key) === -1) { + if (!KeyboardEventManager.activationKeys.has(event.key) || event.repeat) { return; } + this.keyPointer = event.key; this.dispatchEvent(event, EventTypes.DOWN); }; private onKeyUp = (event: KeyboardEvent): void => { - if ( - KeyboardEventManager.activationKeys.indexOf(event.key) === -1 || - !this.isPressed - ) { + if (this.keyPointer !== event.key) { return; } this.dispatchEvent(event, EventTypes.UP); + this.keyPointer = null; }; private dispatchEvent(event: KeyboardEvent, eventType: EventTypes) { @@ -59,15 +59,12 @@ export default class KeyboardEventManager extends EventManager { switch (eventType) { case EventTypes.UP: - this.isPressed = false; this.onPointerUp(adaptedEvent); break; case EventTypes.DOWN: - this.isPressed = true; this.onPointerDown(adaptedEvent); break; case EventTypes.CANCEL: - this.isPressed = false; this.onPointerCancel(adaptedEvent); break; } @@ -103,6 +100,11 @@ export default class KeyboardEventManager extends EventManager { } } + public override resetManager(): void { + super.resetManager(); + this.keyPointer = null; + } + protected mapEvent( event: KeyboardEvent, eventType: EventTypes diff --git a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts index 627cbaac09..bf64dde55f 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -1,6 +1,7 @@ import EventManager from './EventManager'; import { AdaptedEvent, EventTypes } from '../interfaces'; import { PointerType } from '../../PointerType'; +import { calculateViewScale } from '../utils'; export default class WheelEventManager extends EventManager { private wheelDelta = { x: 0, y: 0 }; @@ -19,7 +20,9 @@ export default class WheelEventManager extends EventManager { public registerListeners(): void { this.view.addEventListener('pointermove', this.resetDelta); - this.view.addEventListener('wheel', this.wheelCallback); + this.view.addEventListener('wheel', this.wheelCallback, { + passive: true, + }); } public unregisterListeners(): void { @@ -28,11 +31,17 @@ export default class WheelEventManager extends EventManager { } protected mapEvent(event: WheelEvent): AdaptedEvent { + const rect = this.view.getBoundingClientRect(); + const { scaleX, scaleY } = calculateViewScale(this.view); + + const x = event.clientX + this.wheelDelta.x; + const y = event.clientY + this.wheelDelta.y; + return { - x: event.clientX + this.wheelDelta.x, - y: event.clientY + this.wheelDelta.y, - offsetX: event.offsetX - event.deltaX, - offsetY: event.offsetY - event.deltaY, + x, + y, + offsetX: (x - rect.left) / scaleX, + offsetY: (y - rect.top) / scaleY, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER,