Skip to content

Commit 0b994ac

Browse files
yungstersfacebook-github-bot
authored andcommitted
RN: Create useMergeRefs Utility
Summary: Creates `useMergeRefs` which will be used by components in React Native. Changelog: [Internal] Reviewed By: lunaleaps Differential Revision: D28862439 fbshipit-source-id: 60eac7bcd6cceb06ee82181386b4712d642f5404
1 parent 4fb371b commit 0b994ac

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails oncall+react_native
8+
* @flow strict-local
9+
* @format
10+
*/
11+
12+
import useMergeRefs from '../useMergeRefs';
13+
import * as React from 'react';
14+
import {View} from 'react-native';
15+
import {act, create} from 'react-test-renderer';
16+
17+
/**
18+
* TestView provide a component execution environment to test hooks.
19+
*/
20+
function TestView({name, refs}) {
21+
const mergeRef = useMergeRefs(...refs);
22+
return <View ref={mergeRef} testID={name} />;
23+
}
24+
25+
/**
26+
* TestViewInstance provides a pretty-printable replacement for React instances.
27+
*/
28+
class TestViewInstance {
29+
name: string;
30+
31+
constructor(name: string) {
32+
this.name = name;
33+
}
34+
35+
// $FlowIgnore[unclear-type] - Intentional.
36+
static fromValue(value: any): ?TestViewInstance {
37+
const testID = value?.props?.testID;
38+
return testID == null ? null : new TestViewInstance(testID);
39+
}
40+
41+
static named(name: string) {
42+
// $FlowIssue[prop-missing] - Flow does not support type augmentation.
43+
return expect.testViewInstance(name);
44+
}
45+
}
46+
47+
/**
48+
* extend.testViewInstance makes it easier to assert expected values. But use
49+
* TestViewInstance.named instead of extend.testViewInstance because of Flow.
50+
*/
51+
expect.extend({
52+
testViewInstance(received, name) {
53+
const pass = received instanceof TestViewInstance && received.name === name;
54+
return {pass};
55+
},
56+
});
57+
58+
/**
59+
* Creates a registry that records the values assigned to the mock refs created
60+
* by either of the two returned callbacks.
61+
*/
62+
function mockRefRegistry<T>(): {
63+
mockCallbackRef: (name: string) => T => mixed,
64+
mockObjectRef: (name: string) => {current: T, ...},
65+
registry: $ReadOnlyArray<{[string]: T}>,
66+
} {
67+
const registry = [];
68+
return {
69+
mockCallbackRef: (name: string): (T => mixed) => current => {
70+
registry.push({[name]: TestViewInstance.fromValue(current)});
71+
},
72+
mockObjectRef: (name: string): {current: T, ...} => ({
73+
// $FlowIgnore[unsafe-getters-setters] - Intentional.
74+
set current(current) {
75+
registry.push({[name]: TestViewInstance.fromValue(current)});
76+
},
77+
}),
78+
registry,
79+
};
80+
}
81+
82+
test('accepts a callback ref', () => {
83+
let root;
84+
85+
const {mockCallbackRef, registry} = mockRefRegistry();
86+
const refA = mockCallbackRef('refA');
87+
88+
act(() => {
89+
root = create(<TestView name="foo" refs={[refA]} />);
90+
});
91+
92+
expect(registry).toEqual([{refA: TestViewInstance.named('foo')}]);
93+
94+
act(() => {
95+
root = create(<TestView name="bar" refs={[refA]} />);
96+
});
97+
98+
expect(registry).toEqual([
99+
{refA: TestViewInstance.named('foo')},
100+
{refA: TestViewInstance.named('bar')},
101+
]);
102+
103+
act(() => {
104+
root.unmount();
105+
});
106+
107+
expect(registry).toEqual([
108+
{refA: TestViewInstance.named('foo')},
109+
{refA: TestViewInstance.named('bar')},
110+
{refA: null},
111+
]);
112+
});
113+
114+
test('accepts an object ref', () => {
115+
let root;
116+
117+
const {mockObjectRef, registry} = mockRefRegistry();
118+
const refA = mockObjectRef('refA');
119+
120+
act(() => {
121+
root = create(<TestView name="foo" refs={[refA]} />);
122+
});
123+
124+
expect(registry).toEqual([{refA: TestViewInstance.named('foo')}]);
125+
126+
act(() => {
127+
root = create(<TestView name="bar" refs={[refA]} />);
128+
});
129+
130+
expect(registry).toEqual([
131+
{refA: TestViewInstance.named('foo')},
132+
{refA: TestViewInstance.named('bar')},
133+
]);
134+
135+
act(() => {
136+
root.unmount();
137+
});
138+
139+
expect(registry).toEqual([
140+
{refA: TestViewInstance.named('foo')},
141+
{refA: TestViewInstance.named('bar')},
142+
{refA: null},
143+
]);
144+
});
145+
146+
test('invokes refs in order', () => {
147+
let root;
148+
149+
const {mockCallbackRef, mockObjectRef, registry} = mockRefRegistry();
150+
const refA = mockCallbackRef('refA');
151+
const refB = mockObjectRef('refB');
152+
const refC = mockCallbackRef('refC');
153+
const refD = mockObjectRef('refD');
154+
155+
act(() => {
156+
root = create(<TestView name="foo" refs={[refA, refB, refC, refD]} />);
157+
});
158+
159+
expect(registry).toEqual([
160+
{refA: TestViewInstance.named('foo')},
161+
{refB: TestViewInstance.named('foo')},
162+
{refC: TestViewInstance.named('foo')},
163+
{refD: TestViewInstance.named('foo')},
164+
]);
165+
166+
act(() => {
167+
root.unmount();
168+
});
169+
170+
expect(registry).toEqual([
171+
{refA: TestViewInstance.named('foo')},
172+
{refB: TestViewInstance.named('foo')},
173+
{refC: TestViewInstance.named('foo')},
174+
{refD: TestViewInstance.named('foo')},
175+
{refA: null},
176+
{refB: null},
177+
{refC: null},
178+
{refD: null},
179+
]);
180+
});
181+
182+
// This is actually undesirable behavior, but it's what we have so let's make
183+
// sure it does not change unexpectedly.
184+
test('invokes all refs if any ref changes', () => {
185+
let root;
186+
187+
const {mockCallbackRef, registry} = mockRefRegistry();
188+
const refA = mockCallbackRef('refA');
189+
const refB = mockCallbackRef('refB');
190+
191+
act(() => {
192+
root = create(<TestView name="foo" refs={[refA, refB]} />);
193+
});
194+
195+
expect(registry).toEqual([
196+
{refA: TestViewInstance.named('foo')},
197+
{refB: TestViewInstance.named('foo')},
198+
]);
199+
200+
const refAPrime = mockCallbackRef('refAPrime');
201+
act(() => {
202+
root.update(<TestView name="foo" refs={[refAPrime, refB]} />);
203+
});
204+
205+
expect(registry).toEqual([
206+
{refA: TestViewInstance.named('foo')},
207+
{refB: TestViewInstance.named('foo')},
208+
{refA: null},
209+
{refB: null},
210+
{refAPrime: TestViewInstance.named('foo')},
211+
{refB: TestViewInstance.named('foo')},
212+
]);
213+
214+
act(() => {
215+
root.unmount();
216+
});
217+
218+
expect(registry).toEqual([
219+
{refA: TestViewInstance.named('foo')},
220+
{refB: TestViewInstance.named('foo')},
221+
{refA: null},
222+
{refB: null},
223+
{refAPrime: TestViewInstance.named('foo')},
224+
{refB: TestViewInstance.named('foo')},
225+
{refAPrime: null},
226+
{refB: null},
227+
]);
228+
});

Libraries/Utilities/useMergeRefs.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
*/
10+
11+
import {useCallback} from 'react';
12+
13+
type CallbackRef<T> = T => mixed;
14+
type ObjectRef<T> = {current: T, ...};
15+
16+
type Ref<T> = CallbackRef<T> | ObjectRef<T>;
17+
18+
/**
19+
* Constructs a new ref that forwards new values to each of the given refs. The
20+
* given refs will always be invoked in the order that they are supplied.
21+
*
22+
* WARNING: A known problem of merging refs using this approach is that if any
23+
* of the given refs change, the returned callback ref will also be changed. If
24+
* the returned callback ref is supplied as a `ref` to a React element, this may
25+
* lead to problems with the given refs being invoked more times than desired.
26+
*/
27+
export default function useMergeRefs<T>(
28+
...refs: $ReadOnlyArray<?Ref<T>>
29+
): CallbackRef<T> {
30+
return useCallback(
31+
(current: T) => {
32+
for (const ref of refs) {
33+
if (ref != null) {
34+
if (typeof ref === 'function') {
35+
ref(current);
36+
} else {
37+
ref.current = current;
38+
}
39+
}
40+
}
41+
},
42+
[...refs], // eslint-disable-line react-hooks/exhaustive-deps
43+
);
44+
}

0 commit comments

Comments
 (0)