Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6e882eb
Revert "perf(useRefObjectAsForwardedRef): add dependency array to use…
iansan5653 Mar 6, 2026
288dee9
Add unit tests
iansan5653 Mar 6, 2026
8fea4b7
Add `useCombinedRefs` and deprecate `useRefObjectAsForwardedRefs`
iansan5653 Mar 6, 2026
3b9804f
Migrate Primer components to `useCombinedRefs`
iansan5653 Mar 6, 2026
cef559e
Merge branch 'main' of https://github.com/primer/react into create-us…
iansan5653 Mar 6, 2026
0e14a42
Revert accidental change
iansan5653 Mar 6, 2026
f71d633
Fix typo
iansan5653 Mar 6, 2026
7a905ab
Edit comment
iansan5653 Mar 6, 2026
9361cd5
Remove unecessary variable assignment
iansan5653 Mar 6, 2026
c53642d
Fix docs json
iansan5653 Mar 6, 2026
1079845
Migrate usage for `ButtonBase`, `Dialog`, `Heading`
iansan5653 Mar 9, 2026
f6bc0e5
Fix typo in test name
iansan5653 Mar 9, 2026
c5cc9a5
Merge branch 'create-use-combined-refs' of https://github.com/primer/…
iansan5653 Mar 9, 2026
3f60a77
Fix types to force refs to accept null
iansan5653 Mar 9, 2026
29fbb1f
Update exports snapshot
iansan5653 Mar 9, 2026
ece5160
Fix test
iansan5653 Mar 9, 2026
bdb0e39
Add `useCombinedRefs` hook, deprecate `useRefObjectAsForwardedRef` (#…
Copilot Mar 10, 2026
80f14aa
Improve cleanup logic
iansan5653 Mar 12, 2026
4f9ca43
update tests with correct cleanup behavior
iansan5653 Mar 12, 2026
9d449fb
Merge branch 'main' into create-use-combined-refs
iansan5653 Mar 17, 2026
dcae302
Expose `useMergedRefs` and make ready for React 19
iansan5653 Mar 17, 2026
bb26a62
Add unit tests for `useMergedRefs`
iansan5653 Mar 17, 2026
3d854c9
Update import paths
iansan5653 Mar 17, 2026
8e77831
Deprecate `useProvidedRefOrCreate` and `useRefObjectAsForwardedRef`
iansan5653 Mar 17, 2026
973f184
Add changeset
iansan5653 Mar 17, 2026
86bedb1
Merge branch 'update-use-merged-refs' into create-use-combined-refs
iansan5653 Mar 17, 2026
40ef6b3
Update approach to `useMergedRefs`
iansan5653 Mar 17, 2026
105e866
Update changeset
iansan5653 Mar 17, 2026
bbfc0cb
Potential fix for pull request finding
iansan5653 Mar 17, 2026
7349459
Merge branch 'update-use-merged-refs' into create-use-combined-refs
TylerJDev Mar 17, 2026
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: 5 additions & 0 deletions .changeset/combined-refs-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Update internal implementations of combined refs to improve performance and add support for React 19 callback refs
7 changes: 7 additions & 0 deletions .changeset/early-ravens-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@primer/react": minor
---

- New: Exposes new `useMergedRefs` hook that can merge two refs into a single combined ref
- Deprecates `useRefObjectAsForwardedRef`; see doc comment for migration instructions
- Deprecates `useProvidedRefOrCreate`; see doc comment for migration instructions
1 change: 1 addition & 0 deletions packages/react/src/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ 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'})

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 {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useMergedRefs} from '../hooks/useMergedRefs'
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 mergedRef = useMergedRefs(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={mergedRef}
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 {useMergedRefs} from '../hooks/useMergedRefs'
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 mergedRef = useMergedRefs(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={mergedRef}
top={position?.top}
left={position?.left}
className={clsx(classes.Overlay, className)}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, {forwardRef, useEffect} from 'react'
import {AlertIcon, InfoIcon, StopIcon, CheckCircleIcon, XIcon} from '@primer/octicons-react'
import {Button, IconButton, type ButtonProps} from '../Button'
import {VisuallyHidden} from '../VisuallyHidden'
import {useMergedRefs} from '../internal/hooks/useMergedRefs'
import {useMergedRefs} from '../hooks/useMergedRefs'
import {useId} from '../hooks/useId'
import classes from './Banner.module.css'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
Expand Down
7 changes: 3 additions & 4 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 {useMergedRefs} from '../hooks/useMergedRefs'
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)
const combinedRefs = useMergedRefs(forwardedRef, innerRef)

const uuid = useId(id)
const loadingAnnouncementID = `${uuid}-loading-announcement`
Expand Down Expand Up @@ -87,8 +87,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
<Component
aria-disabled={loading ? true : undefined}
{...rest}
// @ts-ignore temporary disable as we migrate to css modules, until we remove PolymorphicForwardRefComponent
ref={innerRef}
ref={combinedRefs}
className={clsx(classes.ButtonBase, className)}
data-block={block ? 'block' : null}
data-inactive={inactive ? true : undefined}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Details/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useEffect, type ComponentPropsWithoutRef, type ReactElement} from
import {warning} from '../utils/warning'
import {clsx} from 'clsx'
import classes from './Details.module.css'
import {useMergedRefs} from '../internal/hooks/useMergedRefs'
import {useMergedRefs} from '../hooks/useMergedRefs'

const Root = React.forwardRef<HTMLDetailsElement, DetailsProps>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useMergedRefs} from '../hooks/useMergedRefs'
import {useId} from '../hooks/useId'
import {ScrollableRegion} from '../ScrollableRegion'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
Expand Down Expand Up @@ -289,7 +289,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
})

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

useFocusTrap({
Expand Down Expand Up @@ -362,7 +362,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
}}
>
<div
ref={dialogRef}
ref={mergedRef}
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 {useRefObjectAsForwardedRef} from '../hooks'
import {useMergedRefs} 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)
const mergedRef = useMergedRefs(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={innerRef} />
return <Component className={clsx(className, classes.Heading)} data-variant={variant} {...props} ref={mergedRef} />
}) as PolymorphicForwardRefComponent<HeadingLevels, StyledHeadingProps>

Heading.displayName = 'Heading'
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 {useMergedRefs} 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 mergedRef = useMergedRefs(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={mergedRef}
/>
)
}
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 {useMergedRefs} from '../hooks/useMergedRefs'
import type {AnchorSide} from '@primer/behaviors'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import classes from './Overlay.module.css'
Expand Down Expand Up @@ -192,7 +192,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
): ReactElement<any> => {
const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
const overlayRef = useRef<HTMLDivElement>(null)
useRefObjectAsForwardedRef(forwardedRef, overlayRef)
const mergedRef = useMergedRefs(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 @@ -236,7 +236,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
role={role}
width={width}
data-reflow-container={!preventOverflow ? true : undefined}
ref={overlayRef}
ref={mergedRef}
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 {useMergedRefs} from '../hooks/useMergedRefs'
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,
})

useRefObjectAsForwardedRef(forwardRef, paneRef)
const mergedRef = useMergedRefs(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={paneRef}
ref={mergedRef}
// 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,
})

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

const hasOverflow = useOverflow(sidebarRef)

Expand Down Expand Up @@ -1192,7 +1192,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
/>
)}
<div
ref={sidebarRef}
ref={mergedRef}
// 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 {useMergedRefs} from '../hooks/useMergedRefs'
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 mergedRef = useMergedRefs(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={mergedRef}
disabled={disabled}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useFormControlForwardedProps",
"useId",
"useIsomorphicLayoutEffect",
"useMergedRefs",
"useOnEscapePress",
"useOnOutsideClick",
"useOpenAndCloseFocus",
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 {useRefObjectAsForwardedRef} from '../../hooks/useRefObjectAsForwardedRef'
import {useMergedRefs} from '../../hooks/useMergedRefs'
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)
const mergedRef = useMergedRefs(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={modalRef}
ref={mergedRef}
role="dialog"
aria-modal="true"
{...props}
Expand Down
Loading
Loading