diff --git a/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx b/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx
index c783172496..84c7143fca 100644
--- a/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx
+++ b/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx
@@ -1,5 +1,6 @@
import { cleanup, render } from '@testing-library/react-native';
import React from 'react';
+import type { ViewProps } from 'react-native';
import { findNodeHandle, View } from 'react-native';
import {
@@ -59,6 +60,79 @@ describe('VirtualDetector', () => {
'VirtualGestureDetector must be a descendant of an InterceptingGestureDetector'
);
});
+ test('virtual detector reports a child that swallows the ref', () => {
+ (findNodeHandle as jest.Mock).mockReturnValue(123);
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+
+ function SwallowsRef({ style }: { style?: ViewProps['style'] }) {
+ return ;
+ }
+
+ function VirtualDetectorSwallowedRef() {
+ const tap = useTapGesture({});
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const screen = render();
+ screen.rerender();
+
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'VirtualGestureDetector could not attach to its child (SwallowsRef)'
+ )
+ );
+ consoleError.mockRestore();
+ });
+
+ test('virtual detector waits for a child that has no host view yet', () => {
+ (findNodeHandle as jest.Mock).mockReturnValue(null);
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+
+ function LateView({
+ ready,
+ ref,
+ }: {
+ ready: boolean;
+ ref?: React.ComponentProps['ref'];
+ }) {
+ return ready ? : null;
+ }
+
+ function VirtualDetectorLateChild({ ready }: { ready: boolean }) {
+ const tap = useTapGesture({});
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const screen = render();
+
+ (findNodeHandle as jest.Mock).mockReturnValue(123);
+ screen.rerender();
+
+ expect(consoleError).not.toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
test('virtual detector does not handle animated events', () => {
(findNodeHandle as jest.Mock).mockReturnValue(123);
diff --git a/packages/react-native-gesture-handler/src/hostInstance.ts b/packages/react-native-gesture-handler/src/hostInstance.ts
index dfcdb58471..612d97e569 100644
--- a/packages/react-native-gesture-handler/src/hostInstance.ts
+++ b/packages/react-native-gesture-handler/src/hostInstance.ts
@@ -18,6 +18,12 @@ export function assignRef(
return undefined;
}
+// Undoes `assignRef`: runs the cleanup a ref callback returned, otherwise
+// assigns null, which is what React does when no cleanup was returned.
+export function detachRef(ref: WrapRef, cleanup?: () => void) {
+ cleanup ? cleanup() : assignRef(ref, null);
+}
+
export function isHostInstance(instance: unknown) {
return (
(instance as { __internalInstanceHandle?: unknown } | null | undefined)
diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx
index 00ee8dd18b..99854434f4 100644
--- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx
+++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx
@@ -1,4 +1,12 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ComponentType, ReactElement } from 'react';
+import {
+ Children,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { findNodeHandle, Platform } from 'react-native';
import { tagMessage } from '../../../utils';
@@ -14,6 +22,17 @@ import {
} from './useInterceptingDetectorContext';
import { Wrap } from './Wrap';
+function getChildName(children: unknown) {
+ const { type } = Children.only(children) as ReactElement<
+ unknown,
+ string | ComponentType
+ >;
+
+ return typeof type === 'string'
+ ? type
+ : (type.displayName ?? type.name ?? 'Unknown');
+}
+
function useRequiredInterceptingDetectorContext() {
const context = useInterceptingDetectorContext();
if (!context) {
@@ -58,6 +77,31 @@ export function VirtualDetector<
useNativeGestureRole(viewRef, props.children);
+ // Dev-only: a host view under Wrap while the child never handed one back
+ // means the child swallowed the ref. A child with no host view yet is left alone.
+ const wrapRef = useRef(null);
+ const reportedRef = useRef(false);
+
+ useEffect(() => {
+ if (
+ !__DEV__ ||
+ reportedRef.current ||
+ viewRef.current ||
+ wrapRef.current === null ||
+ !findNodeHandle(wrapRef.current)
+ ) {
+ return;
+ }
+
+ reportedRef.current = true;
+
+ console.error(
+ tagMessage(
+ `VirtualGestureDetector could not attach to its child (${getChildName(props.children)}). The child must be a host view or forward its \`ref\` to one.`
+ )
+ );
+ });
+
const handlerTags = useMemo(
() =>
isComposedGesture(props.gesture)
@@ -114,5 +158,14 @@ export function VirtualDetector<
useGestureRelationsUpdater(props.gesture);
- return {props.children};
+ // The web Wrap is a plain element around the child, so its ref is the view.
+ if (Platform.OS === 'web') {
+ return {props.children};
+ }
+
+ return (
+
+ {props.children}
+
+ );
}
diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx
index 14d0313ee8..cdc762c32a 100644
--- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx
+++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx
@@ -1,62 +1,82 @@
import type { PropsWithChildren } from 'react';
-import React, { useCallback } from 'react';
+import React from 'react';
import type { WrapRef } from '../../../hostInstance';
-import { assignRef, preferHostInstance } from '../../../hostInstance';
+import {
+ assignRef,
+ detachRef,
+ preferHostInstance,
+} from '../../../hostInstance';
import { tagMessage } from '../../../utils';
export type { WrapRef };
-export type WrapProps = PropsWithChildren<{ ref?: WrapRef }>;
-
-export const Wrap: React.FunctionComponent = ({ ref, children }) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let child: any;
- try {
- child = React.Children.only(children);
- } catch (e) {
- throw new Error(
- tagMessage(
- `VirtualGestureDetector expects exactly one React element as its child. To use a gesture with multiple views, wrap them in a single parent view and attach the gesture to that.`
- )
- );
- }
+export type WrapProps = PropsWithChildren<{
+ hostRef?: WrapRef;
+}>;
+
+// A class on purpose: the detector walks the fiber from its instance to tell
+// a child that swallowed the ref from one that has no host view yet.
+export class Wrap extends React.Component {
+ private childRef?: WrapRef;
+ private hostRef?: WrapRef;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- const childRef = child.props.ref as WrapRef;
+ private attachRef: ((instance: unknown) => (() => void) | undefined) | null =
+ null;
- const attachRef = useCallback(
- (instance: unknown) => {
+ private createAttachRef(childRef: WrapRef, hostRef: WrapRef) {
+ return (instance: unknown) => {
const childCleanup = assignRef(childRef, instance);
- const forwardedCleanup = assignRef(ref, preferHostInstance(instance));
+ const hostCleanup = assignRef(hostRef, preferHostInstance(instance));
+
const hasCleanup =
- typeof childCleanup === 'function' ||
- typeof forwardedCleanup === 'function';
+ typeof childCleanup === 'function' || typeof hostCleanup === 'function';
if (!hasCleanup) {
return undefined;
}
return () => {
- if (typeof childCleanup === 'function') {
- childCleanup();
- } else {
- assignRef(childRef, null);
- }
- if (typeof forwardedCleanup === 'function') {
- forwardedCleanup();
- } else {
- assignRef(ref, null);
- }
+ detachRef(childRef, childCleanup);
+ detachRef(hostRef, hostCleanup);
};
- },
- [childRef, ref]
- );
+ };
+ }
+
+ override render() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let child: any;
+
+ try {
+ child = React.Children.only(this.props.children);
+ } catch (e) {
+ throw new Error(
+ tagMessage(
+ `VirtualGestureDetector expects exactly one React element as its child. To use a gesture with multiple views, wrap them in a single parent view and attach the gesture to that.`
+ )
+ );
+ }
- return React.cloneElement(
- child,
- { collapsable: false, ref: attachRef },
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- child.props.children
- );
-};
+ const childRef = child.props.ref as WrapRef;
+ const { hostRef } = this.props;
+
+ // A new callback whenever either ref changes, so React re-attaches it.
+ if (
+ this.attachRef === null ||
+ childRef !== this.childRef ||
+ hostRef !== this.hostRef
+ ) {
+ this.childRef = childRef;
+ this.hostRef = hostRef;
+ this.attachRef = this.createAttachRef(childRef, hostRef);
+ }
+
+ return React.cloneElement(
+ child,
+ { collapsable: false, ref: this.attachRef },
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ child.props.children
+ );
+ }
+}