Skip to content

Commit 4de925b

Browse files
jquensekyletsang
andauthored
fix: merged ref behavior in react 19 (#102)
* fix: fix merged ref behavior in react 19 * Update src/useMergedRefs.ts Co-authored-by: Kyle Tsang <[email protected]> --------- Co-authored-by: Kyle Tsang <[email protected]>
1 parent 961d432 commit 4de925b

File tree

2 files changed

+106
-12
lines changed

2 files changed

+106
-12
lines changed

src/useMergedRefs.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,42 @@
1-
import { useMemo } from 'react'
1+
import { RefCallback, useMemo, version } from 'react'
2+
3+
const isReact19 = parseInt(version.split('.')[0]!, 10) >= 19
24

35
type CallbackRef<T> = (ref: T | null) => void
46
type Ref<T> = React.MutableRefObject<T> | CallbackRef<T>
57

68
function toFnRef<T>(ref?: Ref<T> | null) {
7-
return !ref || typeof ref === 'function'
8-
? ref
9-
: (value: T | null) => {
10-
ref.current = value as T
11-
}
9+
if (!ref || typeof ref === 'function') {
10+
return ref
11+
}
12+
13+
return (value: T | null) => {
14+
ref.current = value as T
15+
}
16+
}
17+
18+
function cleanUp<T>(cleanup: unknown, ref: RefCallback<T> | null | undefined) {
19+
if (typeof cleanup === 'function') {
20+
cleanup()
21+
} else if (ref) {
22+
ref(null)
23+
}
1224
}
1325

1426
export function mergeRefs<T>(refA?: Ref<T> | null, refB?: Ref<T> | null) {
15-
const a = toFnRef(refA)
16-
const b = toFnRef(refB)
27+
const refASetter = toFnRef(refA)
28+
const refBSetter = toFnRef(refB)
29+
1730
return (value: T | null) => {
18-
if (a) a(value)
19-
if (b) b(value)
31+
const cleanupA = refASetter?.(value)
32+
const cleanupB = refBSetter?.(value)
33+
34+
if (isReact19) {
35+
return () => {
36+
cleanUp(cleanupA, refASetter)
37+
cleanUp(cleanupB, refBSetter)
38+
}
39+
}
2040
}
2141
}
2242

@@ -36,7 +56,10 @@ export function mergeRefs<T>(refA?: Ref<T> | null, refB?: Ref<T> | null) {
3656
* @param refB A Callback or mutable Ref
3757
* @category refs
3858
*/
39-
function useMergedRefs<T>(refA?: Ref<T> | null, refB?: Ref<T> | null) {
59+
function useMergedRefs<T>(
60+
refA?: Ref<T | null> | null,
61+
refB?: Ref<T | null> | null,
62+
) {
4063
return useMemo(() => mergeRefs(refA, refB), [refA, refB])
4164
}
4265

test/useMergedRefs.test.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import useMergedRefs from '../src/useMergedRefs.js'
55
import { render } from '@testing-library/react'
66

77
describe('useMergedRefs', () => {
8-
it('should return a function that returns mount state', () => {
8+
it('should work with forwardRef', () => {
99
let innerRef: HTMLButtonElement
1010
const outerRef = React.createRef<HTMLButtonElement>()
1111

@@ -18,9 +18,80 @@ describe('useMergedRefs', () => {
1818
return <button ref={mergedRef} {...props} />
1919
})
2020

21+
render(<Button ref={outerRef} />)
22+
23+
expect(innerRef!.tagName).toEqual('BUTTON')
24+
expect(outerRef.current!.tagName).toEqual('BUTTON')
25+
})
26+
27+
it('should work with plain function component', () => {
28+
let innerRef: HTMLButtonElement
29+
const outerRef = React.createRef<HTMLButtonElement>()
30+
31+
const Button = ({ ref }: { ref: React.Ref<HTMLButtonElement> }) => {
32+
const [buttonEl, attachRef] = useCallbackRef<HTMLButtonElement>()
33+
innerRef = buttonEl!
34+
35+
const mergedRef = useMergedRefs(ref, attachRef)
36+
37+
return <button ref={mergedRef} />
38+
}
39+
40+
render(<Button ref={outerRef} />)
41+
42+
expect(innerRef!.tagName).toEqual('BUTTON')
43+
expect(outerRef.current!.tagName).toEqual('BUTTON')
44+
})
45+
46+
it('should call refs with null when unmounting', () => {
47+
let innerRef: HTMLButtonElement | null = null
48+
49+
const outerRef = React.createRef<HTMLButtonElement>()
50+
51+
function Button({ ref }: { ref: React.Ref<HTMLButtonElement> }) {
52+
const mergedRef = useMergedRefs(ref, (value) => {
53+
innerRef = value
54+
})
55+
56+
return <button ref={mergedRef} />
57+
}
58+
59+
const result = render(<Button ref={outerRef} />)
60+
61+
expect(innerRef!.tagName).toEqual('BUTTON')
62+
expect(outerRef.current!.tagName).toEqual('BUTTON')
63+
64+
result.unmount()
65+
66+
expect(innerRef).toBeNull()
67+
expect(outerRef.current).toBeNull()
68+
})
69+
70+
it('should call refs cleanup functions', () => {
71+
let innerRef: HTMLButtonElement | null = null
72+
73+
const outerRef = React.createRef<HTMLButtonElement>()
74+
75+
function Button({ ref }: { ref: React.Ref<HTMLButtonElement> }) {
76+
const mergedRef = useMergedRefs(ref, (value) => {
77+
innerRef = value
78+
79+
return () => {
80+
innerRef = 'hi' as any
81+
}
82+
})
83+
84+
return <button ref={mergedRef} />
85+
}
86+
2187
const result = render(<Button ref={outerRef} />)
2288

2389
expect(innerRef!.tagName).toEqual('BUTTON')
2490
expect(outerRef.current!.tagName).toEqual('BUTTON')
91+
92+
result.unmount()
93+
94+
expect(innerRef).toEqual('hi')
95+
expect(outerRef.current).toBeNull()
2596
})
2697
})

0 commit comments

Comments
 (0)