Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .changeset/combined-refs-hook.md

This file was deleted.

6 changes: 3 additions & 3 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {ActionMenu} from '../ActionMenu'
import {useFocusZone, FocusKeys} from '../hooks/useFocusZone'
import styles from './ActionBar.module.css'
import {clsx} from 'clsx'
import {useMergedRefs} from '../hooks'
import {useRefObjectAsForwardedRef} from '../hooks'
import {createDescendantRegistry} from '../utils/descendant-registry'

const ACTIONBAR_ITEM_GAP = 8
Expand Down Expand Up @@ -470,7 +470,7 @@ function useWidth(ref: React.RefObject<HTMLElement | null>) {
export const ActionBarIconButton = forwardRef(
({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => {
const ref = useRef<HTMLButtonElement>(null)
const mergedRef = useMergedRefs(ref, forwardedRef)
useRefObjectAsForwardedRef(forwardedRef, ref)

const {size, isVisibleChild} = React.useContext(ActionBarContext)
const {groupId} = React.useContext(ActionBarGroupContext)
Expand Down Expand Up @@ -507,7 +507,7 @@ export const ActionBarIconButton = forwardRef(
return (
<IconButton
aria-disabled={disabled}
ref={mergedRef}
ref={ref}
size={size}
onClick={clickHandler}
{...props}
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/ActionList/Heading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {forwardRef} from 'react'
import {useMergedRefs} from '../hooks'
import {useRefObjectAsForwardedRef} from '../hooks'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {default as HeadingComponent} from '../Heading'
import {ListContext} from './shared'
Expand All @@ -21,7 +21,7 @@ export type ActionListHeadingProps = {

export const Heading = forwardRef(({as, size, children, visuallyHidden = false, className, ...props}, forwardedRef) => {
const innerRef = React.useRef<HTMLHeadingElement>(null)
const mergedRef = useMergedRefs(forwardedRef, innerRef)
useRefObjectAsForwardedRef(forwardedRef, innerRef)

const {headingId: headingId, variant: listVariant} = React.useContext(ListContext)
const {container} = React.useContext(ActionListContainerContext)
Expand All @@ -37,7 +37,7 @@ export const Heading = forwardRef(({as, size, children, visuallyHidden = false,
<HeadingComponent
as={as}
variant={size}
ref={mergedRef}
ref={innerRef}
// use custom id if it is provided. Otherwise, use the id from the context
id={props.id ?? headingId}
className={clsx(className, classes.ActionListHeader)}
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ describe('Autocomplete', () => {
const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)

expect(inputNode.getAttribute('aria-expanded')).not.toBe('true')
inputNode.focus()
fireEvent.click(inputNode)
fireEvent.keyDown(inputNode, {key: 'ArrowDown'})

Comment on lines 150 to 155
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Removing inputNode.focus() means this test now relies on fireEvent.click(inputNode) to focus/open the menu, which can make it pass even if the intended “ArrowDown when focused opens menu” behavior regresses (click may open the menu by itself). To keep the assertion targeted, focus the input (or assert the menu remains closed after click) before the ArrowDown keyDown.

Copilot uses AI. Check for mistakes.
Expand Down
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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
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
const mergedRef = useMergedRefs(forwardedRef, inputRef)
useRefObjectAsForwardedRef(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={mergedRef}
ref={inputRef}
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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import VisuallyHidden from '../_VisuallyHidden'

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

const mergedRef = useMergedRefs(scrollContainerRef, floatingElementRef)
useRefObjectAsForwardedRef(scrollContainerRef, floatingElementRef)

const closeOptionList = useCallback(() => {
setShowMenu(false)
Expand All @@ -73,7 +73,7 @@ function AutocompleteOverlay({
preventFocusOnOpen={true}
onClickOutside={closeOptionList}
onEscape={closeOptionList}
ref={mergedRef}
ref={floatingElementRef as React.RefObject<HTMLDivElement>}
top={position?.top}
left={position?.left}
className={clsx(classes.Overlay, className)}
Expand Down
7 changes: 4 additions & 3 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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
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)
const combinedRefs = useMergedRefs(forwardedRef, innerRef)
useRefObjectAsForwardedRef(forwardedRef, innerRef)

const uuid = useId(id)
const loadingAnnouncementID = `${uuid}-loading-announcement`
Expand Down Expand Up @@ -87,7 +87,8 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
<Component
aria-disabled={loading ? true : undefined}
{...rest}
ref={combinedRefs}
// @ts-ignore temporary disable as we migrate to css modules, until we remove PolymorphicForwardRefComponent
ref={innerRef}
Comment on lines +90 to +91
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Avoid introducing @ts-ignore on the ref assignment here; it hides a real typing mismatch between the polymorphic as element and innerRef’s declared element type. Consider typing innerRef to match the union of supported elements (e.g., button/anchor) or switching back to useMergedRefs so forwardedRef can be attached without suppressing TypeScript errors.

Suggested change
// @ts-ignore temporary disable as we migrate to css modules, until we remove PolymorphicForwardRefComponent
ref={innerRef}
ref={
Component === 'a'
? (innerRef as React.Ref<HTMLAnchorElement>)
: (innerRef as React.Ref<HTMLButtonElement>)
}

Copilot uses AI. Check for mistakes.
className={clsx(classes.ButtonBase, className)}
data-block={block ? 'block' : null}
data-inactive={inactive ? true : undefined}
Expand Down
6 changes: 3 additions & 3 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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useId} from '../hooks/useId'
import {ScrollableRegion} from '../ScrollableRegion'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
Expand Down Expand Up @@ -299,7 +299,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
})

const dialogRef = useRef<HTMLDivElement>(null)
const mergedRef = useMergedRefs(forwardedRef, dialogRef)
useRefObjectAsForwardedRef(forwardedRef, dialogRef)
const backdropRef = useRef<HTMLDivElement>(null)

useFocusTrap({
Expand Down Expand Up @@ -388,7 +388,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
}}
>
<div
ref={mergedRef}
ref={dialogRef}
role={role}
aria-labelledby={dialogLabelId}
aria-describedby={dialogDescriptionId}
Expand Down
6 changes: 3 additions & 3 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 {useMergedRefs} from '../hooks'
import {useRefObjectAsForwardedRef} 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)
const mergedRef = useMergedRefs(forwardedRef, innerRef)
useRefObjectAsForwardedRef(forwardedRef, innerRef)

if (__DEV__) {
/**
Expand All @@ -32,7 +32,7 @@ const Heading = forwardRef(({as: Component = 'h2', className, variant, ...props}
}, [innerRef])
}

return <Component className={clsx(className, classes.Heading)} data-variant={variant} {...props} ref={mergedRef} />
return <Component className={clsx(className, classes.Heading)} data-variant={variant} {...props} ref={innerRef} />
}) as PolymorphicForwardRefComponent<HeadingLevels, StyledHeadingProps>

Heading.displayName = 'Heading'
Expand Down
7 changes: 4 additions & 3 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 {useMergedRefs} from '../hooks'
import {useRefObjectAsForwardedRef} 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, muted, hoverColor, ...restProps} = props
const innerRef = React.useRef<ElementRef<As>>(null)
const mergedRef = useMergedRefs(ref, innerRef)
useRefObjectAsForwardedRef(ref, innerRef)

if (__DEV__) {
/**
Expand Down Expand Up @@ -53,7 +53,8 @@ export const UnwrappedLink = <As extends React.ElementType = 'a'>(
data-inline={inline}
data-hover-color={hoverColor}
{...restProps}
ref={mergedRef}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={innerRef as any}
Comment on lines 53 to +57
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

ref={innerRef as any} (and disabling the lint rule) bypasses type-safety for the polymorphic as prop and can mask cases where the rendered component doesn’t support refs. Prefer keeping the ref fully typed (e.g., by using useMergedRefs again, or by expressing the polymorphic ref type explicitly) so TS can enforce that ref is valid for As without resorting to any.

Copilot uses AI. Check for mistakes.
/>
)
}
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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import type {AnchorSide} from '@primer/behaviors'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import classes from './Overlay.module.css'
Expand Down Expand Up @@ -194,7 +194,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
): ReactElement<any> => {
const featureFlagMaxHeightClampToViewport = useFeatureFlag('primer_react_overlay_max_height_clamp_to_viewport')
const overlayRef = useRef<HTMLDivElement>(null)
const mergedRef = useMergedRefs(forwardedRef, overlayRef)
useRefObjectAsForwardedRef(forwardedRef, overlayRef)
const slideAnimationDistance = 8 // var(--base-size-8), hardcoded to do some math
Comment on lines 195 to 198
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Switching back to useRefObjectAsForwardedRef reintroduces its known behavior of invoking callback refs on every re-render (see useRefObjectAsForwardedRef tests’ TODO), which can cause unnecessary work/side effects and is especially risky with React 19 callback refs that return cleanup functions. Prefer useMergedRefs here so refs are only updated on mount/unmount/target changes and React 19 cleanup refs are handled correctly.

Copilot uses AI. Check for mistakes.
const slideAnimationEasing = 'cubic-bezier(0.33, 1, 0.68, 1)'
const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
Expand Down Expand Up @@ -239,7 +239,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
role={role}
width={width}
data-reflow-container={!preventOverflow ? true : undefined}
ref={mergedRef}
ref={overlayRef}
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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
import {isResponsiveValue} from '../hooks/useResponsiveValue'
import {useSlots} from '../hooks/useSlots'
Expand Down Expand Up @@ -838,7 +838,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
currentWidth: controlledWidth,
})

const mergedRef = useMergedRefs(forwardRef, paneRef)
useRefObjectAsForwardedRef(forwardRef, paneRef)

const hasOverflow = useOverflow(paneRef)

Expand Down Expand Up @@ -887,7 +887,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
position={positionProp}
/>
<div
ref={mergedRef}
ref={paneRef}
// 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 @@ -1136,7 +1136,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
constrainToViewport: true,
})

const mergedRef = useMergedRefs(forwardRef, sidebarRef)
useRefObjectAsForwardedRef(forwardRef, sidebarRef)

const hasOverflow = useOverflow(sidebarRef)

Expand Down Expand Up @@ -1192,7 +1192,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
/>
)}
<div
ref={mergedRef}
ref={sidebarRef}
// 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 {useMergedRefs} from '../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
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()
const mergedRef = useMergedRefs(forwardedRef, ref)
useRefObjectAsForwardedRef(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={mergedRef}
ref={ref}
disabled={disabled}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
Expand Down
6 changes: 3 additions & 3 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 {useMergedRefs} from '../../hooks/useMergedRefs'
import {useRefObjectAsForwardedRef} from '../../hooks/useRefObjectAsForwardedRef'
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)
const mergedRef = useMergedRefs(forwardedRef, modalRef)
useRefObjectAsForwardedRef(forwardedRef, modalRef)
const closeButtonRef = useRef(null)

const onCloseClick = () => {
Expand All @@ -73,7 +73,7 @@ const Dialog = forwardRef<HTMLDivElement, InternalDialogProps>(
<span className={classes.Overlay} ref={overlayRef} />
<Component
tabIndex={-1}
ref={mergedRef}
ref={modalRef}
role="dialog"
aria-modal="true"
{...props}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/hooks/__tests__/useMergedRefs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ type InputOrButtonRef = RefObject<HTMLInputElement & HTMLButtonElement>
const Component = forwardRef<HTMLInputElement & HTMLButtonElement, {asButton?: boolean}>(({asButton}, forwardedRef) => {
const ref: InputOrButtonRef = React.useRef(null)

const mergedRef = useMergedRefs(forwardedRef, ref)
const combinedRef = useMergedRefs(forwardedRef, ref)

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

describe('useMergedRefs', () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/hooks/useMergedRefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@ import {useCallback} from 'react'
* // React 18
* const Example = forwardRef<HTMLButtonElement, {}>((props, forwardedRef) => {
* const ref = useRef<HTMLButtonElement>(null)
* const mergedRef = useMergedRefs(forwardedRef, ref)
* const combinedRef = useMergedRefs(forwardedRef, ref)
*
* return <button ref={mergedRef} />
* return <button ref={combinedRef} />
* })
*
* @example
* // React 19
* const Example = ({ref: externalRef}: {ref?: Ref<HTMLButtonElement>}) => {
* const ref = useRef<HTMLButtonElement>(null)
* const mergedRef = useMergedRefs(externalRef, ref)
* const combinedRef = useMergedRefs(externalRef, ref)
*
* return <button ref={mergedRef} />
* return <button ref={combinedRef} />
* }
*/
export function useMergedRefs<T>(refA: Ref<T | null>, refB: Ref<T | null>) {
Expand Down
Loading