Skip to content

Commit 7292285

Browse files
feat: Support Ref Cleanup (#7758)
* feat: Support Ref Cleanup * cleanup * add some preliminary mergeRef tests --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: GitHub <[email protected]>
1 parent 271277c commit 7292285

File tree

4 files changed

+172
-23
lines changed

4 files changed

+172
-23
lines changed

packages/@react-aria/utils/src/mergeRefs.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,43 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ForwardedRef, MutableRefObject} from 'react';
13+
import {MutableRefObject, Ref} from 'react';
1414

1515
/**
1616
* Merges multiple refs into one. Works with either callback or object refs.
1717
*/
18-
export function mergeRefs<T>(...refs: Array<ForwardedRef<T> | MutableRefObject<T> | null | undefined>): ForwardedRef<T> {
18+
export function mergeRefs<T>(...refs: Array<Ref<T> | MutableRefObject<T> | null | undefined>): Ref<T> {
1919
if (refs.length === 1 && refs[0]) {
2020
return refs[0];
2121
}
2222

2323
return (value: T | null) => {
24-
for (let ref of refs) {
25-
if (typeof ref === 'function') {
26-
ref(value);
27-
} else if (ref != null) {
28-
ref.current = value;
29-
}
24+
let hasCleanup = false;
25+
26+
const cleanups = refs.map(ref => {
27+
const cleanup = setRef(ref, value);
28+
hasCleanup ||= typeof cleanup == 'function';
29+
return cleanup;
30+
});
31+
32+
if (hasCleanup) {
33+
return () => {
34+
cleanups.forEach((cleanup, i) => {
35+
if (typeof cleanup === 'function') {
36+
cleanup();
37+
} else {
38+
setRef(refs[i], null);
39+
}
40+
});
41+
};
3042
}
3143
};
3244
}
45+
46+
function setRef<T>(ref: Ref<T> | MutableRefObject<T> | null | undefined, value: T) {
47+
if (typeof ref === 'function') {
48+
return ref(value);
49+
} else if (ref != null) {
50+
ref.current = value;
51+
}
52+
}

packages/@react-aria/utils/src/useObjectRef.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,60 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {MutableRefObject, useMemo, useRef} from 'react';
13+
import {MutableRefObject, useCallback, useMemo, useRef} from 'react';
1414

1515
/**
1616
* Offers an object ref for a given callback ref or an object ref. Especially
1717
* helfpul when passing forwarded refs (created using `React.forwardRef`) to
1818
* React Aria hooks.
1919
*
20-
* @param forwardedRef The original ref intended to be used.
20+
* @param ref The original ref intended to be used.
2121
* @returns An object ref that updates the given ref.
22-
* @see https://reactjs.org/docs/forwarding-refs.html
22+
* @see https://react.dev/reference/react/forwardRef
2323
*/
24-
export function useObjectRef<T>(forwardedRef?: ((instance: T | null) => void) | MutableRefObject<T | null> | null): MutableRefObject<T | null> {
24+
export function useObjectRef<T>(ref?: ((instance: T | null) => (() => void) | void) | MutableRefObject<T | null> | null): MutableRefObject<T | null> {
2525
const objRef: MutableRefObject<T | null> = useRef<T>(null);
26-
return useMemo(() => ({
27-
get current() {
28-
return objRef.current;
26+
const cleanupRef: MutableRefObject<(() => void) | void> = useRef(undefined);
27+
28+
const refEffect = useCallback(
29+
(instance: T | null) => {
30+
if (typeof ref === 'function') {
31+
const refCallback = ref;
32+
const refCleanup = refCallback(instance);
33+
return () => {
34+
if (typeof refCleanup === 'function') {
35+
refCleanup();
36+
} else {
37+
refCallback(null);
38+
}
39+
};
40+
} else if (ref) {
41+
ref.current = instance;
42+
return () => {
43+
ref.current = null;
44+
};
45+
}
2946
},
30-
set current(value) {
31-
objRef.current = value;
32-
if (typeof forwardedRef === 'function') {
33-
forwardedRef(value);
34-
} else if (forwardedRef) {
35-
forwardedRef.current = value;
47+
[ref]
48+
);
49+
50+
return useMemo(
51+
() => ({
52+
get current() {
53+
return objRef.current;
54+
},
55+
set current(value) {
56+
objRef.current = value;
57+
if (cleanupRef.current) {
58+
cleanupRef.current();
59+
cleanupRef.current = undefined;
60+
}
61+
62+
if (value != null) {
63+
cleanupRef.current = refEffect(value);
64+
}
3665
}
37-
}
38-
}), [forwardedRef]);
66+
}),
67+
[refEffect]
68+
);
3969
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {mergeRefs} from '../';
14+
import React, {useCallback, useRef} from 'react';
15+
import {render} from '@react-spectrum/test-utils-internal';
16+
17+
describe('mergeRefs', () => {
18+
it('merge Refs', () => {
19+
let ref1;
20+
let ref2;
21+
22+
const TextField = (props) => {
23+
ref1 = useRef(null);
24+
ref2 = useRef(null);
25+
26+
const ref = mergeRefs(ref1, ref2);
27+
return <input {...props} ref={ref} />;
28+
};
29+
30+
render(<TextField foo="foo" />);
31+
32+
expect(ref1.current).toBe(ref2.current);
33+
});
34+
35+
if (parseInt(React.version.split('.')[0], 10) >= 19) {
36+
it('merge Ref Cleanup', () => {
37+
const cleanUp = jest.fn();
38+
let ref1;
39+
let ref2;
40+
let target = null;
41+
42+
const TextField = (props) => {
43+
ref1 = useRef(null);
44+
ref2 = useRef(null);
45+
let ref3 = useCallback((node) => {
46+
target = node;
47+
return cleanUp;
48+
}, []);
49+
50+
const ref = mergeRefs(ref1, ref2, ref3);
51+
return <input {...props} ref={ref} />;
52+
};
53+
54+
const {unmount} = render(<TextField foo="foo" />);
55+
56+
expect(cleanUp).toHaveBeenCalledTimes(0);
57+
expect(ref1.current).toBe(target);
58+
expect(ref2.current).toBe(target);
59+
unmount();
60+
61+
// Now cleanup has been called
62+
expect(cleanUp).toHaveBeenCalledTimes(1);
63+
});
64+
}
65+
});

packages/@react-aria/utils/test/useObjectRef.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,38 @@ describe('useObjectRef', () => {
146146
expect(screen.getAllByPlaceholderText(/foo/i)).toHaveLength(2);
147147
});
148148
});
149+
150+
it('calls cleanup function on unmount', () => {
151+
const cleanUp = jest.fn();
152+
const setup = jest.fn();
153+
const nullHandler = jest.fn();
154+
155+
function ref(_ref) {
156+
if (_ref) {
157+
setup();
158+
} else {
159+
nullHandler();
160+
}
161+
return cleanUp;
162+
}
163+
164+
const TextField = React.forwardRef((props, forwardedRef) => {
165+
const ref = useObjectRef(forwardedRef);
166+
return <input {...props} ref={ref} />;
167+
});
168+
169+
const {unmount} = render(<TextField ref={ref} />);
170+
171+
expect(setup).toHaveBeenCalledTimes(1);
172+
expect(cleanUp).toHaveBeenCalledTimes(0);
173+
expect(nullHandler).toHaveBeenCalledTimes(0);
174+
175+
unmount();
176+
177+
expect(setup).toHaveBeenCalledTimes(1);
178+
// Now cleanup has been called
179+
expect(cleanUp).toHaveBeenCalledTimes(1);
180+
// Ref callback never called with null when cleanup is returned
181+
expect(nullHandler).toHaveBeenCalledTimes(0);
182+
});
149183
});

0 commit comments

Comments
 (0)