Skip to content
6 changes: 3 additions & 3 deletions packages/react/src/Autocomplete/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, {useCallback, useContext, useEffect, useState} from 'react'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {AutocompleteContext, AutocompleteInputContext} from './AutocompleteContext'
import TextInput from '../TextInput'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import type {ComponentProps} from '../utils/types'
import useSafeTimeout from '../hooks/useSafeTimeout'

Expand Down Expand Up @@ -43,7 +43,7 @@ const AutocompleteInput = React.forwardRef(
}
const {activeDescendantRef, id, inputRef, setInputValue, setShowMenu, showMenu} = autocompleteContext
const {autocompleteSuggestion = '', inputValue = '', isMenuDirectlyActivated} = inputContext
useRefObjectAsForwardedRef(forwardedRef, inputRef)
const combinedRef = useCombinedRefs(forwardedRef, inputRef)
const [highlightRemainingText, setHighlightRemainingText] = useState<boolean>(true)
const {safeSetTimeout} = useSafeTimeout()

Expand Down Expand Up @@ -160,7 +160,7 @@ const AutocompleteInput = React.forwardRef(
onKeyDown={handleInputKeyDown}
onKeyPress={onInputKeyPress}
onKeyUp={handleInputKeyUp}
ref={inputRef}
ref={combinedRef}
aria-controls={`${id}-listbox`}
aria-autocomplete="both"
role="combobox"
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/Autocomplete/AutocompleteOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {OverlayProps} from '../Overlay'
import Overlay from '../Overlay'
import type {ComponentProps} from '../utils/types'
import {AutocompleteContext} from './AutocompleteContext'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import VisuallyHidden from '../_VisuallyHidden'

import classes from './AutocompleteOverlay.module.css'
Expand Down Expand Up @@ -57,7 +57,7 @@ function AutocompleteOverlay({
[showMenu, selectedItemLength],
)

useRefObjectAsForwardedRef(scrollContainerRef, floatingElementRef)
const combinedRef = useCombinedRefs(scrollContainerRef, floatingElementRef)

const closeOptionList = useCallback(() => {
setShowMenu(false)
Expand All @@ -73,7 +73,7 @@ function AutocompleteOverlay({
preventFocusOnOpen={true}
onClickOutside={closeOptionList}
onEscape={closeOptionList}
ref={floatingElementRef as React.RefObject<HTMLDivElement>}
ref={combinedRef}
top={position?.top}
left={position?.left}
className={clsx(classes.Overlay, className)}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, {forwardRef, type JSX} from 'react'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import type {ButtonProps} from './types'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import {VisuallyHidden} from '../VisuallyHidden'
import Spinner from '../Spinner'
import CounterLabel from '../CounterLabel'
Expand Down Expand Up @@ -51,7 +51,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
} = props

const innerRef = React.useRef<HTMLButtonElement>(null)
useRefObjectAsForwardedRef(forwardedRef, innerRef)
useCombinedRefs(forwardedRef, innerRef)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCombinedRefs(forwardedRef, innerRef) is called but its return value isn't used; the component still passes ref={innerRef}. This breaks external/forwarded refs. Assign the combined ref and pass it to the rendered Component’s ref prop.

Suggested change
useCombinedRefs(forwardedRef, innerRef)
const combinedRef = useCombinedRefs(forwardedRef, innerRef)

Copilot uses AI. Check for mistakes.

const uuid = useId(id)
const loadingAnnouncementID = `${uuid}-loading-announcement`
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {XIcon} from '@primer/octicons-react'
import {useFocusZone} from '../hooks/useFocusZone'
import {FocusKeys} from '@primer/behaviors'
import Portal from '../Portal'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import {useId} from '../hooks/useId'
import {ScrollableRegion} from '../ScrollableRegion'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
Expand Down Expand Up @@ -288,7 +288,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
})

const dialogRef = useRef<HTMLDivElement>(null)
useRefObjectAsForwardedRef(forwardedRef, dialogRef)
useCombinedRefs(forwardedRef, dialogRef)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCombinedRefs(forwardedRef, dialogRef) is invoked but the returned ref callback isn't used; the dialog element still uses ref={dialogRef}. This means the forwarded ref will not be set. Capture the combined ref and pass it to the dialog element’s ref prop.

Suggested change
useCombinedRefs(forwardedRef, dialogRef)
const combinedDialogRef = useCombinedRefs(forwardedRef, dialogRef)

Copilot uses AI. Check for mistakes.
const backdropRef = useRef<HTMLDivElement>(null)

useFocusTrap({
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/Heading/Heading.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {clsx} from 'clsx'
import React, {forwardRef, useEffect} from 'react'
import {useRefObjectAsForwardedRef} from '../hooks'
import {useCombinedRefs} from '../hooks'
import type {ComponentProps} from '../utils/types'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import classes from './Heading.module.css'
Expand All @@ -14,7 +14,7 @@ type StyledHeadingProps = {

const Heading = forwardRef(({as: Component = 'h2', className, variant, ...props}, forwardedRef) => {
const innerRef = React.useRef<HTMLHeadingElement>(null)
useRefObjectAsForwardedRef(forwardedRef, innerRef)
useCombinedRefs(forwardedRef, innerRef)

Comment on lines 15 to 18
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result of useCombinedRefs is ignored, so the forwarded ref is no longer attached to the rendered element. Store the returned ref callback (e.g., const combinedRef = useCombinedRefs(...)) and pass that to ref instead of innerRef.

Copilot uses AI. Check for mistakes.
if (__DEV__) {
/**
Expand Down
7 changes: 3 additions & 4 deletions packages/react/src/Link/Link.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {clsx} from 'clsx'
import React, {useEffect, type ForwardedRef, type ElementRef} from 'react'
import {useRefObjectAsForwardedRef} from '../hooks'
import {useCombinedRefs} from '../hooks'
import classes from './Link.module.css'
import type {ComponentProps} from '../utils/types'
import {type PolymorphicProps, fixedForwardRef} from '../utils/modern-polymorphic'
Expand All @@ -20,7 +20,7 @@ export const UnwrappedLink = <As extends React.ElementType = 'a'>(
) => {
const {as: Component = 'a', className, inline, hoverColor, ...restProps} = props
const innerRef = React.useRef<ElementRef<As>>(null)
useRefObjectAsForwardedRef(ref, innerRef)
const combinedRef = useCombinedRefs(ref, innerRef)

if (__DEV__) {
/**
Expand Down Expand Up @@ -53,8 +53,7 @@ export const UnwrappedLink = <As extends React.ElementType = 'a'>(
data-inline={inline}
data-hover-color={hoverColor}
{...restProps}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={innerRef as any}
ref={combinedRef}
/>
)
}
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {AriaRole, Merge} from '../utils/types'
import type {TouchOrMouseEvent} from '../hooks'
import {useOverlay} from '../hooks'
import Portal from '../Portal'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import type {AnchorSide} from '@primer/behaviors'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import classes from './Overlay.module.css'
Expand Down Expand Up @@ -190,7 +190,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ReactElement<any> => {
const overlayRef = useRef<HTMLDivElement>(null)
useRefObjectAsForwardedRef(forwardedRef, overlayRef)
const combinedRef = useCombinedRefs(forwardedRef, overlayRef)
const slideAnimationDistance = 8 // var(--base-size-8), hardcoded to do some math
const slideAnimationEasing = 'cubic-bezier(0.33, 1, 0.68, 1)'

Expand Down Expand Up @@ -235,7 +235,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
role={role}
width={width}
data-reflow-container={!preventOverflow ? true : undefined}
ref={overlayRef}
ref={combinedRef}
left={leftPosition}
right={right}
height={height}
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, {memo, useRef} from 'react'
import {clsx} from 'clsx'
import {useId} from '../hooks/useId'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
import {isResponsiveValue} from '../hooks/useResponsiveValue'
import {useSlots} from '../hooks/useSlots'
Expand Down Expand Up @@ -868,7 +868,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
currentWidth: controlledWidth,
})

useRefObjectAsForwardedRef(forwardRef, paneRef)
const combinedRef = useCombinedRefs(forwardRef, paneRef)

const hasOverflow = useOverflow(paneRef)

Expand Down Expand Up @@ -917,7 +917,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
position={positionProp}
/>
<div
ref={paneRef}
ref={combinedRef}
// Suppress hydration mismatch for --pane-width when localStorage
// provides a width that differs from the server-rendered default.
// Not needed when onResizeEnd is provided (localStorage isn't read).
Expand Down Expand Up @@ -1166,7 +1166,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
constrainToViewport: true,
})

useRefObjectAsForwardedRef(forwardRef, sidebarRef)
const combinedRef = useCombinedRefs(forwardRef, sidebarRef)

const hasOverflow = useOverflow(sidebarRef)

Expand Down Expand Up @@ -1222,7 +1222,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
/>
)}
<div
ref={sidebarRef}
ref={combinedRef}
// Suppress hydration mismatch for --pane-width when localStorage
// provides a width that differs from the server-rendered default.
suppressHydrationWarning={resizable === true && !!widthStorageKey}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {isFocusable} from '@primer/behaviors/utils'
import type {FocusEventHandler, KeyboardEventHandler, MouseEventHandler, RefObject} from 'react'
import React, {useRef, useState} from 'react'
import {isValidElementType} from 'react-is'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import {useFocusZone} from '../hooks/useFocusZone'
import {useId} from '../hooks/useId'
import Text from '../Text'
Expand Down Expand Up @@ -108,7 +108,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
const ref = useRef<HTMLInputElement>(null)

const selectedValuesDescriptionId = useId()
useRefObjectAsForwardedRef(forwardedRef, ref)
const combinedRef = useCombinedRefs(forwardedRef, ref)
const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
const selectedTokenTexts = tokens
Expand Down Expand Up @@ -310,7 +310,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
>
<div className={styles.InputWrapper}>
<UnstyledTextInput
ref={ref}
ref={combinedRef}
disabled={disabled}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useOverlay",
"useProvidedRefOrCreate",
"useRefObjectAsForwardedRef",
"useCombinedRefs",
"useResizeObserver",
"useResponsiveValue",
"useSafeTimeout",
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/deprecated/DialogV1/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {forwardRef, useRef, type HTMLAttributes} from 'react'
import {IconButton} from '../../Button'
import useDialog from '../../hooks/useDialog'
import type {ComponentProps} from '../../utils/types'
import {useRefObjectAsForwardedRef} from '../../hooks/useRefObjectAsForwardedRef'
import {useCombinedRefs} from '../../hooks/useCombinedRefs'
import {XIcon} from '@primer/octicons-react'
import {clsx} from 'clsx'
import classes from './Dialog.module.css'
Expand Down Expand Up @@ -48,7 +48,7 @@ const Dialog = forwardRef<HTMLDivElement, InternalDialogProps>(
) => {
const overlayRef = useRef(null)
const modalRef = useRef<HTMLDivElement>(null)
useRefObjectAsForwardedRef(forwardedRef, modalRef)
useCombinedRefs(forwardedRef, modalRef)
const closeButtonRef = useRef(null)
Comment on lines 49 to 52
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCombinedRefs(forwardedRef, modalRef) is called but the return value is ignored and the rendered Component still uses ref={modalRef}. This drops the forwarded ref behavior for DialogV1; use the combined ref as the ref prop.

Copilot uses AI. Check for mistakes.

const onCloseClick = () => {
Expand Down
137 changes: 137 additions & 0 deletions packages/react/src/hooks/__tests__/useCombinedRefs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {render, renderHook} from '@testing-library/react'
import React, {forwardRef, type RefObject} from 'react'
import {describe, expect, it, vi} from 'vitest'
import {useCombinedRefs} from '../useCombinedRefs'

type InputOrButtonRef = RefObject<HTMLInputElement & HTMLButtonElement>

const Component = forwardRef<HTMLInputElement & HTMLButtonElement, {asButton?: boolean}>(({asButton}, forwardedRef) => {
const ref: InputOrButtonRef = React.useRef(null)

const combinedRef = useCombinedRefs(forwardedRef, ref)

return asButton ? <button type="button" ref={combinedRef} /> : <input ref={combinedRef} />
})

describe('useCombinedRefs', () => {
describe('combining a forwarded ref with an internal ref', () => {
describe('object refs', () => {
it('fowards the ref to the underlying element', async () => {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in test name: "fowards" should be "forwards".

Suggested change
it('fowards the ref to the underlying element', async () => {
it('forwards the ref to the underlying element', async () => {

Copilot uses AI. Check for mistakes.
const ref: InputOrButtonRef = {current: null}

render(<Component ref={ref} />)

expect(ref.current).toBeInstanceOf(HTMLInputElement)
})

it('avoids dangling references by clearing the ref on unmount', () => {
const ref: InputOrButtonRef = {current: null}

const {unmount} = render(<Component ref={ref} />)

expect(ref.current).toBeInstanceOf(HTMLInputElement)

unmount()

expect(ref.current).toBeNull()
})

it('updates the ref if the target changes', () => {
const ref: InputOrButtonRef = {current: null}

const {rerender} = render(<Component ref={ref} />)

expect(ref.current).toBeInstanceOf(HTMLInputElement)

rerender(<Component ref={ref} asButton />)

expect(ref.current).toBeInstanceOf(HTMLButtonElement)
})

it('is correctly set during an initial effect', () => {
// This can break if the hook delays setting the initial value until a subsequent render

const ComponentWithEffect = () => {
const ref = React.useRef<HTMLInputElement & HTMLButtonElement>(null)

React.useEffect(() => {
expect(ref.current).toBeInstanceOf(HTMLInputElement)
}, [])

return <Component ref={ref} />
}

render(<ComponentWithEffect />)
})
})

describe('callback refs', () => {
it('calls the ref on mount and unmount', async () => {
const ref = vi.fn()

const {unmount} = render(<Component ref={ref} />)

expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement))

unmount()

expect(ref).toHaveBeenCalledWith(null)
})

it('calls the ref if the target changes', () => {
const ref = vi.fn()

const {rerender} = render(<Component ref={ref} />)

expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement))

rerender(<Component ref={ref} asButton />)

expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement))
})

it('does not call the ref on re-render if the target does not change', () => {
const ref = vi.fn()

const {rerender} = render(<Component ref={ref} />)

rerender(<Component ref={ref} />)

expect(ref).toHaveBeenCalledExactlyOnceWith(expect.any(HTMLInputElement))
})
})
})

describe('combining two callback refs', () => {
it('calls both refs when the combined ref is called', () => {
const refA = vi.fn()
const refB = vi.fn()

const combined = renderHook(() => useCombinedRefs(refA, refB))

combined.result.current('test')
expect(refA).toHaveBeenCalledExactlyOnceWith('test')
expect(refB).toHaveBeenCalledExactlyOnceWith('test')
})

it('handles cleanup functions correctly and independently', () => {
const refA = vi.fn()
const cleanupRefB = vi.fn()
const refB = vi.fn().mockReturnValue(cleanupRefB)

const combined = renderHook(() => useCombinedRefs(refA, refB))

combined.result.current('test')
expect(refA).toHaveBeenCalledWith('test')
expect(refB).toHaveBeenCalledWith('test')

combined.result.current(null)
expect(refA).toHaveBeenCalledWith(null)
expect(refB).not.toHaveBeenCalledWith(null)
expect(cleanupRefB).not.toHaveBeenCalled()

combined.unmount()
expect(cleanupRefB).toHaveBeenCalledOnce()
})
})
})
Loading
Loading