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)