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..82920007a6 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,41 @@ describe('VirtualDetector', () => {
'VirtualGestureDetector must be a descendant of an InterceptingGestureDetector'
);
});
+
+ test('virtual detector reports a child that swallows the ref', () => {
+ 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 does not handle animated events', () => {
(findNodeHandle as jest.Mock).mockReturnValue(123);
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..008ddef80d 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,24 @@ export function VirtualDetector<
useNativeGestureRole(viewRef, props.children);
+ // Dev-only: no host instance after a commit means the child did not hand
+ // its `ref` to one. A child that renders null first is reported as well.
+ const reportedRef = useRef(false);
+
+ useEffect(() => {
+ if (!__DEV__ || reportedRef.current || viewRef.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)