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 + ); + } +}