Skip to content

Commit 0b82de0

Browse files
authored
Chore: ref forwarding to child in wrap v2 (#4385)
1 parent 90df738 commit 0b82de0

8 files changed

Lines changed: 318 additions & 51 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { cleanup, render } from '@testing-library/react-native';
2+
import React from 'react';
3+
import { findNodeHandle, View } from 'react-native';
4+
5+
import { Gesture, GestureDetector, GestureHandlerRootView } from '../index';
6+
import RNGestureHandlerModule from '../RNGestureHandlerModule';
7+
8+
jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({
9+
findNodeHandle: jest.fn(),
10+
}));
11+
12+
const VIEW_TAG = 123;
13+
14+
function ChildIgnoringRef(props: { children?: React.ReactNode }) {
15+
return <View>{props.children}</View>;
16+
}
17+
18+
class ChildWithHostInstance extends React.Component<{
19+
children?: React.ReactNode;
20+
}> {
21+
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
22+
public __internalInstanceHandle = {};
23+
24+
override render() {
25+
return <View>{this.props.children}</View>;
26+
}
27+
}
28+
29+
describe('Legacy GestureDetector ref forwarding', () => {
30+
let attachSpy: jest.SpyInstance;
31+
32+
beforeEach(() => {
33+
cleanup();
34+
jest.clearAllMocks();
35+
(findNodeHandle as jest.Mock).mockReturnValue(VIEW_TAG);
36+
attachSpy = jest.spyOn(RNGestureHandlerModule, 'attachGestureHandler');
37+
});
38+
39+
afterEach(() => {
40+
attachSpy.mockRestore();
41+
});
42+
43+
test('resolves the tag from the child host instance when its ref resolves', () => {
44+
render(
45+
<GestureHandlerRootView>
46+
<GestureDetector gesture={Gesture.Tap()}>
47+
<ChildWithHostInstance />
48+
</GestureDetector>
49+
</GestureHandlerRootView>
50+
);
51+
52+
const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map(
53+
(call) => call[0]
54+
);
55+
56+
expect(resolvedRefs.length).toBeGreaterThan(0);
57+
expect(
58+
resolvedRefs.every(
59+
(ref) => ref instanceof ChildWithHostInstance && ref !== null
60+
)
61+
).toBe(true);
62+
expect(attachSpy).toHaveBeenCalledWith(
63+
expect.any(Number),
64+
VIEW_TAG,
65+
expect.any(Number)
66+
);
67+
});
68+
69+
test('attaches gestures when the child ignores its ref', () => {
70+
render(
71+
<GestureHandlerRootView>
72+
<GestureDetector gesture={Gesture.Tap()}>
73+
<ChildIgnoringRef />
74+
</GestureDetector>
75+
</GestureHandlerRootView>
76+
);
77+
78+
const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map(
79+
(call) => call[0]
80+
);
81+
82+
expect(resolvedRefs.length).toBeGreaterThan(0);
83+
expect(
84+
resolvedRefs.every((ref) => ref instanceof ChildWithHostInstance)
85+
).toBe(false);
86+
expect(attachSpy).toHaveBeenCalledWith(
87+
expect.any(Number),
88+
VIEW_TAG,
89+
expect.any(Number)
90+
);
91+
});
92+
93+
test('does not clobber a ref the child already has', () => {
94+
const childRef = jest.fn();
95+
96+
render(
97+
<GestureHandlerRootView>
98+
<GestureDetector gesture={Gesture.Tap()}>
99+
<View ref={childRef} />
100+
</GestureDetector>
101+
</GestureHandlerRootView>
102+
);
103+
104+
expect(childRef).toHaveBeenCalled();
105+
expect(childRef.mock.calls[0][0]).not.toBeNull();
106+
});
107+
108+
test('moves the child instance when its ref is replaced', () => {
109+
const gesture = Gesture.Tap();
110+
const firstRef = jest.fn();
111+
const secondRef = jest.fn();
112+
113+
function App({ useSecondRef }: { useSecondRef: boolean }) {
114+
return (
115+
<GestureHandlerRootView>
116+
<GestureDetector gesture={gesture}>
117+
<View ref={useSecondRef ? secondRef : firstRef} />
118+
</GestureDetector>
119+
</GestureHandlerRootView>
120+
);
121+
}
122+
123+
const { rerender } = render(<App useSecondRef={false} />);
124+
125+
const childInstance = firstRef.mock.calls[0][0];
126+
expect(childInstance).not.toBeNull();
127+
firstRef.mockClear();
128+
129+
rerender(<App useSecondRef />);
130+
131+
expect(firstRef).toHaveBeenCalledWith(null);
132+
expect(secondRef).toHaveBeenCalledWith(childInstance);
133+
});
134+
135+
test('does not reattach gestures on re-render', () => {
136+
const gesture = Gesture.Tap();
137+
138+
function App() {
139+
return (
140+
<GestureHandlerRootView>
141+
<GestureDetector gesture={gesture}>
142+
<View />
143+
</GestureDetector>
144+
</GestureHandlerRootView>
145+
);
146+
}
147+
148+
const { rerender } = render(<App />);
149+
expect(attachSpy).toHaveBeenCalledTimes(1);
150+
151+
rerender(<App />);
152+
rerender(<App />);
153+
154+
expect(attachSpy).toHaveBeenCalledTimes(1);
155+
});
156+
});

‎packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isHostInstance } from './hostInstance';
2+
13
// Used by GestureDetector (unsupported on web at the moment) to check whether the
24
// attached view may get flattened on Fabric. This implementation causes errors
35
// on web due to the static resolution of `require` statements by webpack breaking
@@ -8,8 +10,10 @@ let getInternalInstanceHandleFromPublicInstance: (ref: unknown) => {
810
};
911

1012
export function getShadowNodeFromRef(ref: unknown) {
13+
const isAlreadyHostInstance = isHostInstance(ref);
14+
1115
// Load findHostInstance_DEPRECATED lazily because it may not be available before render
12-
if (findHostInstance_DEPRECATED === undefined) {
16+
if (!isAlreadyHostInstance && findHostInstance_DEPRECATED === undefined) {
1317
try {
1418
// eslint-disable-next-line @typescript-eslint/no-var-requires
1519
const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric');
@@ -43,8 +47,11 @@ export function getShadowNodeFromRef(ref: unknown) {
4347
}
4448
}
4549

50+
const hostInstance = isAlreadyHostInstance
51+
? ref
52+
: findHostInstance_DEPRECATED(ref);
53+
4654
// @ts-ignore Fabric
47-
return getInternalInstanceHandleFromPublicInstance(
48-
findHostInstance_DEPRECATED(ref)
49-
).stateNode.node;
55+
return getInternalInstanceHandleFromPublicInstance(hostInstance).stateNode
56+
.node;
5057
}

‎packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx‎

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import React from 'react';
22

3+
import type { WrapRef } from '../../../hostInstance';
4+
import {
5+
assignRef,
6+
isHostInstance,
7+
preferHostInstance,
8+
} from '../../../hostInstance';
39
import { tagMessage } from '../../../utils';
410
import { Reanimated } from '../reanimatedWrapper';
511

@@ -8,27 +14,86 @@ export class Wrap extends React.Component<{
814
// Implicit `children` prop has been removed in @types/react^18.0.0
915
children?: React.ReactNode;
1016
}> {
17+
private childInstance: unknown = null;
18+
private hostInstance: unknown = null;
19+
private childRef: WrapRef = undefined;
20+
private attachedChildRef: WrapRef = undefined;
21+
private childRefCleanup: (() => void) | undefined = undefined;
22+
23+
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
24+
public getHostInstance() {
25+
return this.hostInstance;
26+
}
27+
28+
private detachChildRef() {
29+
if (this.childRefCleanup !== undefined) {
30+
this.childRefCleanup();
31+
} else if (this.attachedChildRef) {
32+
assignRef(this.attachedChildRef, null);
33+
}
34+
35+
this.childRefCleanup = undefined;
36+
this.attachedChildRef = undefined;
37+
}
38+
39+
private attachChildRef(instance: unknown) {
40+
this.attachedChildRef = this.childRef;
41+
42+
this.childRefCleanup = assignRef(this.attachedChildRef, instance);
43+
}
44+
45+
private handleChildRef = (instance: unknown) => {
46+
this.childInstance = instance;
47+
48+
const resolved = preferHostInstance(instance);
49+
this.hostInstance = isHostInstance(resolved) ? resolved : null;
50+
51+
this.detachChildRef();
52+
53+
if (instance !== null && instance !== undefined) {
54+
this.attachChildRef(instance);
55+
}
56+
};
57+
58+
override componentDidUpdate() {
59+
if (
60+
this.childRef === this.attachedChildRef ||
61+
this.childInstance === null
62+
) {
63+
return;
64+
}
65+
66+
this.detachChildRef();
67+
this.attachChildRef(this.childInstance);
68+
}
69+
1170
override render() {
71+
// I don't think that fighting with types over such a simple function is worth it
72+
// The only thing it does is add 'collapsable: false' to the child component
73+
// to make sure it is in the native view hierarchy so the detector can find
74+
// correct viewTag to attach to.
75+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
76+
let child: any;
77+
1278
try {
13-
// I don't think that fighting with types over such a simple function is worth it
14-
// The only thing it does is add 'collapsable: false' to the child component
15-
// to make sure it is in the native view hierarchy so the detector can find
16-
// correct viewTag to attach to.
17-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18-
const child: any = React.Children.only(this.props.children);
19-
return React.cloneElement(
20-
child,
21-
{ collapsable: false },
22-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
23-
child.props.children
24-
);
79+
child = React.Children.only(this.props.children);
2580
} catch (e) {
2681
throw new Error(
2782
tagMessage(
2883
`GestureDetector got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.`
2984
)
3085
);
3186
}
87+
88+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
89+
this.childRef = child.props.ref as WrapRef;
90+
91+
return React.cloneElement(
92+
child,
93+
{ collapsable: false, ref: this.handleChildRef },
94+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
95+
child.props.children
96+
);
3297
}
3398
}
3499

‎packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { useEffect, useMemo, useRef } from 'react';
33

44
import findNodeHandle from '../../../findNodeHandle';
5+
import { resolveHostInstance } from '../../../hostInstance';
56
import { useIsomorphicLayoutEffect } from '../../../useIsomorphicLayoutEffect';
67
import type { TouchAction, UserSelect } from '../../gestureHandlerCommon';
78
import type { GestureType } from '../gesture';
@@ -139,7 +140,9 @@ export const GestureDetector = (props: GestureDetectorProps) => {
139140
useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent);
140141

141142
useIsomorphicLayoutEffect(() => {
142-
const viewTag = findNodeHandle(state.viewRef) as number;
143+
const viewTag = findNodeHandle(
144+
resolveHostInstance(state.viewRef)
145+
) as number;
143146
preparedGesture.isMounted = true;
144147

145148
attachHandlers({

‎packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type React from 'react';
22
import { useCallback } from 'react';
33

44
import findNodeHandle from '../../../findNodeHandle';
5+
import { resolveHostInstance } from '../../../hostInstance';
56
import type { PropsRef } from '../../../web/interfaces';
67
import type { GestureType } from '../gesture';
78
import type { ComposedGesture } from '../gestureComposition';
@@ -27,7 +28,9 @@ export function useDetectorUpdater(
2728
// skipConfigUpdate is used to prevent unnecessary updates when only checking if the view has changed
2829
(skipConfigUpdate?: boolean) => {
2930
// If the underlying view has changed we need to reattach handlers to the new view
30-
const viewTag = findNodeHandle(state.viewRef) as number;
31+
const viewTag = findNodeHandle(
32+
resolveHostInstance(state.viewRef)
33+
) as number;
3134
const didUnderlyingViewChange = viewTag !== state.previousViewTag;
3235

3336
if (

‎packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
33

44
import findNodeHandle from '../../../findNodeHandle';
55
import { getShadowNodeFromRef } from '../../../getShadowNodeFromRef';
6+
import { resolveHostInstance } from '../../../hostInstance';
67
import { tagMessage } from '../../../utils';
78
import type { GestureDetectorState } from './types';
89

@@ -27,7 +28,9 @@ export function useViewRefHandler(
2728

2829
// if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed
2930
if (state.previousViewTag === -1) {
30-
state.previousViewTag = findNodeHandle(state.viewRef) as number;
31+
state.previousViewTag = findNodeHandle(
32+
resolveHostInstance(state.viewRef)
33+
) as number;
3134
}
3235

3336
// Pass true as `skipConfigUpdate`. Here we only want to trigger the eventual reattaching of handlers
@@ -37,7 +40,7 @@ export function useViewRefHandler(
3740
}
3841

3942
if (__DEV__ && global._isViewFlatteningDisabled) {
40-
const node = getShadowNodeFromRef(ref);
43+
const node = getShadowNodeFromRef(resolveHostInstance(ref));
4144
if (global._isViewFlatteningDisabled(node) === false) {
4245
console.error(
4346
tagMessage(

0 commit comments

Comments
 (0)