Skip to content

Commit fe34834

Browse files
committed
[useForkRef] Support ref cleanup functions
1 parent 7faf118 commit fe34834

File tree

2 files changed

+90
-37
lines changed

2 files changed

+90
-37
lines changed

packages/react/src/utils/useForkRef.test.tsx

+49
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
33
import { createRenderer, MuiRenderResult, screen } from '@mui/internal-test-utils';
4+
import { spy } from 'sinon';
45
import { useForkRef } from './useForkRef';
56
import { getReactElementRef } from './getReactElementRef';
67

@@ -123,4 +124,52 @@ describe('useForkRef', () => {
123124
expect(secondRightRef.current!.id).to.equal('test');
124125
});
125126
});
127+
128+
test('calls clean up function if it exists', () => {
129+
const cleanUp = spy();
130+
const setup = spy();
131+
const setup2 = spy();
132+
const nullHandler = spy();
133+
134+
function onRefChangeWithCleanup(ref: HTMLDivElement | null) {
135+
if (ref) {
136+
setup(ref.id);
137+
} else {
138+
nullHandler();
139+
}
140+
return cleanUp;
141+
}
142+
143+
function onRefChangeWithoutCleanup(ref: HTMLDivElement | null) {
144+
if (ref) {
145+
setup2(ref.id);
146+
} else {
147+
nullHandler();
148+
}
149+
}
150+
151+
function App() {
152+
const ref = useForkRef(onRefChangeWithCleanup, onRefChangeWithoutCleanup);
153+
return <div id="test" ref={ref} />;
154+
}
155+
156+
const { unmount } = render(<App />);
157+
158+
expect(setup.args[0][0]).to.equal('test');
159+
expect(setup.callCount).to.equal(1);
160+
expect(cleanUp.callCount).to.equal(0);
161+
162+
expect(setup2.args[0][0]).to.equal('test');
163+
expect(setup2.callCount).to.equal(1);
164+
165+
unmount();
166+
167+
expect(setup.callCount).to.equal(1);
168+
expect(cleanUp.callCount).to.equal(1);
169+
170+
// Setup was not called again
171+
expect(setup2.callCount).to.equal(1);
172+
// Null handler hit because no cleanup is returned
173+
expect(nullHandler.callCount).to.equal(1);
174+
});
126175
});

packages/react/src/utils/useForkRef.ts

+41-37
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,56 @@
1-
'use client';
21
import * as React from 'react';
32

43
/**
5-
* Takes an array of refs and returns a new ref which will apply any modification to all of the refs.
6-
* This is useful when you want to have the ref used in multiple places.
7-
*
8-
* ```tsx
9-
* const rootRef = React.useRef<Instance>(null);
10-
* const refFork = useForkRef(rootRef, props.ref);
11-
*
12-
* return (
13-
* <Root {...props} ref={refFork} />
14-
* );
15-
* ```
16-
*
17-
* @param {Array<React.Ref<Instance> | undefined>} refs The ref array.
18-
* @returns {React.RefCallback<Instance> | null} The new ref callback.
4+
* Merges refs into a single memoized callback ref or `null`.
195
*/
206
export function useForkRef<Instance>(
217
...refs: Array<React.Ref<Instance> | undefined>
22-
): React.RefCallback<Instance> | null {
23-
/**
24-
* This will create a new function if the refs passed to this hook change and are all defined.
25-
* This means react will call the old forkRef with `null` and the new forkRef
26-
* with the ref. Cleanup naturally emerges from this behavior.
27-
*/
8+
): null | React.RefCallback<Instance> {
9+
const cleanupRef = React.useRef<void | (() => void)>(undefined);
10+
11+
const refEffect = React.useCallback((instance: Instance | null) => {
12+
const cleanups = refs.map((ref) => {
13+
if (ref == null) {
14+
return null;
15+
}
16+
17+
if (typeof ref === 'function') {
18+
const refCallback = ref;
19+
const refCleanup: void | (() => void) = refCallback(instance);
20+
return typeof refCleanup === 'function'
21+
? refCleanup
22+
: () => {
23+
refCallback(null);
24+
};
25+
}
26+
27+
(ref as React.RefObject<Instance | null>).current = instance;
28+
return () => {
29+
(ref as React.RefObject<Instance | null>).current = null;
30+
};
31+
});
32+
33+
return () => {
34+
cleanups.forEach((refCleanup) => refCleanup?.());
35+
};
36+
// eslint-disable-next-line react-hooks/exhaustive-deps
37+
}, refs);
38+
2839
return React.useMemo(() => {
2940
if (refs.every((ref) => ref == null)) {
3041
return null;
3142
}
3243

33-
return (instance) => {
34-
refs.forEach((ref) => {
35-
setRef(ref, instance);
36-
});
44+
return (value) => {
45+
if (cleanupRef.current) {
46+
cleanupRef.current();
47+
(cleanupRef as React.RefObject<void | (() => void)>).current = undefined;
48+
}
49+
50+
if (value != null) {
51+
(cleanupRef as React.RefObject<void | (() => void)>).current = refEffect(value);
52+
}
3753
};
38-
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- intentionally ignoring that the dependency array must be an array literal
3954
// eslint-disable-next-line react-hooks/exhaustive-deps
4055
}, refs);
4156
}
42-
43-
function setRef<T>(
44-
ref: React.RefObject<T | null> | ((instance: T | null) => void) | null | undefined,
45-
value: T | null,
46-
): void {
47-
if (typeof ref === 'function') {
48-
ref(value);
49-
} else if (ref) {
50-
ref.current = value;
51-
}
52-
}

0 commit comments

Comments
 (0)