From 3bfcf35030b08f29d43bf86d601369de842dc2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 14 Oct 2026 17:29:31 +0200 Subject: [PATCH 1/2] Report a VirtualGestureDetector child that swallows the ref --- .../src/__tests__/Errors.test.tsx | 74 +++++++++++++ .../src/hostInstance.ts | 6 + .../VirtualDetector/VirtualDetector.tsx | 57 +++++++++- .../src/v3/detectors/VirtualDetector/Wrap.tsx | 104 +++++++++++------- 4 files changed, 197 insertions(+), 44 deletions(-) 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 + ); + } +} From ad32a5e520301dc89c29e0bad4ed9d700a2b1e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 16 Oct 2026 10:20:56 +0200 Subject: [PATCH 2/2] Leave late-mounted views --- .../src/__tests__/Errors.test.tsx | 40 +------ .../src/hostInstance.ts | 6 - .../VirtualDetector/VirtualDetector.tsx | 24 +--- .../src/v3/detectors/VirtualDetector/Wrap.tsx | 104 +++++++----------- 4 files changed, 47 insertions(+), 127 deletions(-) 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 84c7143fca..82920007a6 100644 --- a/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx @@ -60,8 +60,8 @@ 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); @@ -95,44 +95,6 @@ describe('VirtualDetector', () => { 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 612d97e569..dfcdb58471 100644 --- a/packages/react-native-gesture-handler/src/hostInstance.ts +++ b/packages/react-native-gesture-handler/src/hostInstance.ts @@ -18,12 +18,6 @@ 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 99854434f4..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 @@ -77,19 +77,12 @@ 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); + // 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 || - wrapRef.current === null || - !findNodeHandle(wrapRef.current) - ) { + if (!__DEV__ || reportedRef.current || viewRef.current) { return; } @@ -158,14 +151,5 @@ export function VirtualDetector< useGestureRelationsUpdater(props.gesture); - // 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} - - ); + 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 cdc762c32a..14d0313ee8 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,82 +1,62 @@ import type { PropsWithChildren } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; import type { WrapRef } from '../../../hostInstance'; -import { - assignRef, - detachRef, - preferHostInstance, -} from '../../../hostInstance'; +import { assignRef, preferHostInstance } from '../../../hostInstance'; import { tagMessage } from '../../../utils'; export type { WrapRef }; -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; +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.` + ) + ); + } - private attachRef: ((instance: unknown) => (() => void) | undefined) | null = - null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const childRef = child.props.ref as WrapRef; - private createAttachRef(childRef: WrapRef, hostRef: WrapRef) { - return (instance: unknown) => { + const attachRef = useCallback( + (instance: unknown) => { const childCleanup = assignRef(childRef, instance); - const hostCleanup = assignRef(hostRef, preferHostInstance(instance)); - + const forwardedCleanup = assignRef(ref, preferHostInstance(instance)); const hasCleanup = - typeof childCleanup === 'function' || typeof hostCleanup === 'function'; + typeof childCleanup === 'function' || + typeof forwardedCleanup === 'function'; if (!hasCleanup) { return undefined; } return () => { - detachRef(childRef, childCleanup); - detachRef(hostRef, hostCleanup); + if (typeof childCleanup === 'function') { + childCleanup(); + } else { + assignRef(childRef, null); + } + if (typeof forwardedCleanup === 'function') { + forwardedCleanup(); + } else { + assignRef(ref, null); + } }; - }; - } - - 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.` - ) - ); - } + }, + [childRef, ref] + ); + return React.cloneElement( + child, + { collapsable: false, ref: attachRef }, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - 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 - ); - } -} + child.props.children + ); +};