Skip to content

Commit 77735f9

Browse files
iansan5653Copilot
andauthored
Update useMergedRefs to be React 19 compatible and public + deprecate useRefObjectAsForwardedRef and useProvidedRefOrCreate (#7672)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 14886f8 commit 77735f9

12 files changed

Lines changed: 296 additions & 18 deletions

File tree

.changeset/early-ravens-visit.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
- New: Exposes new `useMergedRefs` hook that can merge two refs into a single combined ref
6+
- Deprecates `useRefObjectAsForwardedRef`; see doc comment for migration instructions
7+
- Deprecates `useProvidedRefOrCreate`; see doc comment for migration instructions

packages/react/src/Banner/Banner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, {forwardRef, useEffect} from 'react'
33
import {AlertIcon, InfoIcon, StopIcon, CheckCircleIcon, XIcon} from '@primer/octicons-react'
44
import {Button, IconButton, type ButtonProps} from '../Button'
55
import {VisuallyHidden} from '../VisuallyHidden'
6-
import {useMergedRefs} from '../internal/hooks/useMergedRefs'
6+
import {useMergedRefs} from '../hooks/useMergedRefs'
77
import {useId} from '../hooks/useId'
88
import classes from './Banner.module.css'
99
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

packages/react/src/Details/Details.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useEffect, type ComponentPropsWithoutRef, type ReactElement} from
22
import {warning} from '../utils/warning'
33
import {clsx} from 'clsx'
44
import classes from './Details.module.css'
5-
import {useMergedRefs} from '../internal/hooks/useMergedRefs'
5+
import {useMergedRefs} from '../hooks/useMergedRefs'
66

77
const Root = React.forwardRef<HTMLDetailsElement, DetailsProps>(
88
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/react/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
218218
"useFormControlForwardedProps",
219219
"useId",
220220
"useIsomorphicLayoutEffect",
221+
"useMergedRefs",
221222
"useOnEscapePress",
222223
"useOnOutsideClick",
223224
"useOpenAndCloseFocus",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {render, renderHook} from '@testing-library/react'
2+
import React, {forwardRef, type RefObject} from 'react'
3+
import {describe, expect, it, vi} from 'vitest'
4+
import {useMergedRefs} from '../useMergedRefs'
5+
6+
type InputOrButtonRef = RefObject<HTMLInputElement & HTMLButtonElement>
7+
8+
const Component = forwardRef<HTMLInputElement & HTMLButtonElement, {asButton?: boolean}>(({asButton}, forwardedRef) => {
9+
const ref: InputOrButtonRef = React.useRef(null)
10+
11+
const combinedRef = useMergedRefs(forwardedRef, ref)
12+
13+
return asButton ? <button type="button" ref={combinedRef} /> : <input ref={combinedRef} />
14+
})
15+
16+
describe('useMergedRefs', () => {
17+
describe('combining a forwarded ref with an internal ref', () => {
18+
describe('object refs', () => {
19+
it('forwards the ref to the underlying element', async () => {
20+
const ref: InputOrButtonRef = {current: null}
21+
22+
render(<Component ref={ref} />)
23+
24+
expect(ref.current).toBeInstanceOf(HTMLInputElement)
25+
})
26+
27+
it('avoids dangling references by clearing the ref on unmount', () => {
28+
const ref: InputOrButtonRef = {current: null}
29+
30+
const {unmount} = render(<Component ref={ref} />)
31+
32+
expect(ref.current).toBeInstanceOf(HTMLInputElement)
33+
34+
unmount()
35+
36+
expect(ref.current).toBeNull()
37+
})
38+
39+
it('updates the ref if the target changes', () => {
40+
const ref: InputOrButtonRef = {current: null}
41+
42+
const {rerender} = render(<Component ref={ref} />)
43+
44+
expect(ref.current).toBeInstanceOf(HTMLInputElement)
45+
46+
rerender(<Component ref={ref} asButton />)
47+
48+
expect(ref.current).toBeInstanceOf(HTMLButtonElement)
49+
})
50+
51+
it('is correctly set during an initial effect', () => {
52+
// This can break if the hook delays setting the initial value until a subsequent render
53+
54+
const ComponentWithEffect = () => {
55+
const ref = React.useRef<HTMLInputElement & HTMLButtonElement>(null)
56+
57+
React.useEffect(() => {
58+
expect(ref.current).toBeInstanceOf(HTMLInputElement)
59+
}, [])
60+
61+
return <Component ref={ref} />
62+
}
63+
64+
render(<ComponentWithEffect />)
65+
})
66+
})
67+
68+
describe('callback refs', () => {
69+
it('calls the ref on mount and unmount', async () => {
70+
const ref = vi.fn()
71+
72+
const {unmount} = render(<Component ref={ref} />)
73+
74+
expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement))
75+
76+
unmount()
77+
78+
expect(ref).toHaveBeenCalledWith(null)
79+
})
80+
81+
it('calls the ref if the target changes', () => {
82+
const ref = vi.fn()
83+
84+
const {rerender} = render(<Component ref={ref} />)
85+
86+
expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement))
87+
88+
rerender(<Component ref={ref} asButton />)
89+
90+
expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement))
91+
})
92+
93+
it('does not call the ref on re-render if the target does not change', () => {
94+
const ref = vi.fn()
95+
96+
const {rerender} = render(<Component ref={ref} />)
97+
98+
rerender(<Component ref={ref} />)
99+
100+
expect(ref).toHaveBeenCalledExactlyOnceWith(expect.any(HTMLInputElement))
101+
})
102+
})
103+
})
104+
105+
describe('combining two callback refs', () => {
106+
it('calls both refs when the combined ref is called', () => {
107+
const refA = vi.fn()
108+
const refB = vi.fn()
109+
110+
const combined = renderHook(() => useMergedRefs(refA, refB))
111+
112+
combined.result.current('test')
113+
expect(refA).toHaveBeenCalledExactlyOnceWith('test')
114+
expect(refB).toHaveBeenCalledExactlyOnceWith('test')
115+
})
116+
117+
it('handles React 18 null values correctly', () => {
118+
const refA = vi.fn()
119+
const refB = vi.fn()
120+
121+
const combined = renderHook(() => useMergedRefs(refA, refB))
122+
123+
combined.result.current('test')
124+
expect(refA).toHaveBeenCalledWith('test')
125+
expect(refB).toHaveBeenCalledWith('test')
126+
127+
// on React 18, cleanup fn will be ignored and ref will be called with null
128+
combined.result.current(null)
129+
130+
expect(refA).toHaveBeenCalledWith(null)
131+
expect(refB).toHaveBeenCalledWith(null)
132+
})
133+
134+
it('handles React 19 cleanup functions correctly and independently', () => {
135+
const refA = vi.fn()
136+
const cleanupRefB = vi.fn()
137+
const refB = vi.fn().mockReturnValue(cleanupRefB)
138+
139+
const combined = renderHook(() => useMergedRefs(refA, refB))
140+
141+
const cleanup = combined.result.current('test')
142+
expect(refA).toHaveBeenCalledWith('test')
143+
expect(refB).toHaveBeenCalledWith('test')
144+
145+
// React 19 will call cleanup function and not pass null
146+
cleanup()
147+
148+
expect(refA).toHaveBeenCalledWith(null)
149+
expect(refB).not.toHaveBeenCalledWith(null)
150+
expect(cleanupRefB).toHaveBeenCalledOnce()
151+
})
152+
})
153+
})

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export {useMnemonics} from './useMnemonics'
1616
export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
1717
export {useId} from './useId'
1818
export {useIsMacOS} from './useIsMacOS'
19+
export {useMergedRefs} from './useMergedRefs'
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "useMergedRefs",
3+
"importPath": "@primer/react",
4+
"stories": [],
5+
"parameters": [
6+
{
7+
"name": "refA",
8+
"type": "React.Ref<T> | undefined",
9+
"required": true,
10+
"description": "First ref to combine."
11+
},
12+
{
13+
"name": "refB",
14+
"type": "React.Ref<T> | undefined",
15+
"required": true,
16+
"description": "Second ref to combine."
17+
}
18+
],
19+
"returns": {
20+
"type": "React.RefCallback<T>"
21+
}
22+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type {ForwardedRef, Ref as StandardRef, MutableRefObject} from 'react'
2+
import {useCallback} from 'react'
3+
4+
/**
5+
* Combine two refs of matching type (typically an external or forwarded ref and an internal `useRef` object or
6+
* callback ref).
7+
*
8+
* If you need to combine more than two refs (what are you doing?) just nest the hook:
9+
* `useMergedRefs(refA, useMergedRefs(refB, refC))`
10+
*
11+
* @param refA First ref to merge. The order is not important.
12+
* @param refB Second ref to merge. The order is not important.
13+
* @returns A new ref which must be passed to the relevant child component. **Important**: do not pass `refA` or
14+
* `refB` to the component!
15+
*
16+
* @example
17+
* // React 18
18+
* const Example = forwardRef<HTMLButtonElement, {}>((props, forwardedRef) => {
19+
* const ref = useRef<HTMLButtonElement>(null)
20+
* const combinedRef = useMergedRefs(forwardedRef, ref)
21+
*
22+
* return <button ref={combinedRef} />
23+
* })
24+
*
25+
* @example
26+
* // React 19
27+
* const Example = ({ref: externalRef}: {ref?: Ref<HTMLButtonElement>}) => {
28+
* const ref = useRef<HTMLButtonElement>(null)
29+
* const combinedRef = useMergedRefs(externalRef, ref)
30+
*
31+
* return <button ref={combinedRef} />
32+
* }
33+
*/
34+
export function useMergedRefs<T>(refA: Ref<T | null>, refB: Ref<T | null>) {
35+
return useCallback(
36+
(value: T | null) => {
37+
const cleanupA = setRef(refA, value)
38+
const cleanupB = setRef(refB, value)
39+
40+
// Only works in React 19. In React 18, the cleanup function will be ignored and the ref will get called with
41+
// `null` which will be passed to each ref as expected.
42+
return () => {
43+
// For object refs and callback refs that don't return cleanups, we still need to pass `null` on cleanup
44+
if (cleanupA) cleanupA()
45+
else setRef(refA, null)
46+
47+
if (cleanupB) cleanupB()
48+
else setRef(refB, null)
49+
}
50+
},
51+
[refA, refB],
52+
)
53+
}
54+
55+
type CleanupFunction = () => void
56+
57+
/**
58+
* React 19 supports callback refs that can return a cleanup function. If a cleanup function is returned, the
59+
* cleanup is called on unmount **instead** of setting the ref to null.
60+
*/
61+
// bivarianceHack copied from React types
62+
type React19RefCallback<T> = {
63+
bivarianceHack(instance: T): void | CleanupFunction
64+
}['bivarianceHack']
65+
66+
/**
67+
* Supporting React 18 and 19 while alleviating the need for any hacks or casts in consumers:
68+
* - `ForwardedRef` from the React 18 `forwardRef` HOC
69+
* - `React19RefCallback` for callback refs that can return a cleanup function (this is included in `Ref` in React 19
70+
* but not in 18)
71+
* - `Ref` for standard refs from `useRef` or passed in as React 19 prop
72+
* - `undefined` to allow for easy use of optional `ref` props in React 19
73+
*/
74+
type Ref<T> = ForwardedRef<T> | React19RefCallback<T> | StandardRef<T> | undefined
75+
76+
function setRef<T>(ref: Ref<T>, value: T) {
77+
if (typeof ref === 'function') {
78+
return ref(value)
79+
} else if (ref) {
80+
// `React.Ref` is typed as immutable to protect consumers but it's OK to mutate it here. We could just change the
81+
// type to only accept mutable refs, but then it would be harder to accept refs as props in React 19 because we
82+
// would have to use the `React.ForwardedRef` type instead of `React.Ref`
83+
;(ref as MutableRefObject<T>).current = value
84+
}
85+
}

packages/react/src/hooks/useProvidedRefOrCreate.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import React from 'react'
77
* This hook aims to encapsulate that logic, so the consumer doesn't need to be concerned with violating `rules-of-hooks`.
88
* @param providedRef The ref to use - if undefined, will use the ref from a call to React.useRef
99
* @type TRef The type of the RefObject which should be created.
10+
*
11+
* @deprecated This hook is incompatible with forwarded callback refs. Prefer `useMergedRefs` with an internally
12+
* created ref instead.
13+
*
14+
* ```diff
15+
* - const ref = useProvidedRefOrCreate(forwardedRef as RefObject<...>)
16+
* + const ref = useRef(null)
17+
* + const mergedRef = useMergedRefs(forwardedRef, ref)
18+
*
19+
* - return <div ref={ref} />
20+
* + return <div ref={mergedRef} />
21+
* ```
1022
*/
1123
export function useProvidedRefOrCreate<TRef>(providedRef?: React.RefObject<TRef | null>): React.RefObject<TRef | null> {
1224
const createdRef = React.useRef<TRef>(null)

packages/react/src/hooks/useRefObjectAsForwardedRef.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import {useImperativeHandle} from 'react'
77
* instance with `.current`.
88
*
99
* **NOTE**: The `refObject` should be passed to the underlying element, NOT the `forwardedRef`.
10+
*
11+
* @deprecated Migrate to `useMergedRefs`. It's safer, faster, and easier to use:
12+
*
13+
* ```diff
14+
* const ref = useRef(null)
15+
*
16+
* - useRefObjectAsForwardedRef(forwardedRef, ref)
17+
* + const mergedRef = useMergedRefs(forwardedRef, ref)
18+
*
19+
* - return <div ref={ref} />
20+
* + return <div ref={mergedRef} />
21+
* ```
1022
*/
1123
export function useRefObjectAsForwardedRef<T>(forwardedRef: ForwardedRef<T>, refObject: RefObject<T | null>): void {
1224
useImperativeHandle<T | null, T | null>(forwardedRef, () => refObject.current)

0 commit comments

Comments
 (0)