Skip to content
Draft
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
10 changes: 5 additions & 5 deletions packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, {type JSX} from 'react'
import React, {useRef, type JSX} from 'react'
import {fixedForwardRef} from '../utils/modern-polymorphic'
import {ActionListContainerContext} from './ActionListContainerContext'
import {useSlots} from '../hooks/useSlots'
import {Heading} from './Heading'
import {useId} from '../hooks/useId'
import {ListContext, type ActionListProps} from './shared'
import {useProvidedRefOrCreate} from '../hooks'
import {useMergedRefs} from '../hooks'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import {clsx} from 'clsx'
import classes from './ActionList.module.css'
Expand Down Expand Up @@ -41,7 +41,8 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(

const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy
const listRole = role || listRoleFromContainer
const listRef = useProvidedRefOrCreate(forwardedRef as React.RefObject<HTMLUListElement>)
const listRef = useRef<HTMLElement>(null)
const mergedRef = useMergedRefs(forwardedRef, listRef)

let enableFocusZone = false
if (enableFocusZoneFromContainer !== undefined) enableFocusZone = enableFocusZoneFromContainer
Expand Down Expand Up @@ -69,12 +70,11 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
return (
<ListContext.Provider value={listContextValue}>
{slots.heading}
{/* @ts-expect-error ref needs a non nullable ref */}
<Component
className={clsx(classes.ActionList, className)}
role={listRole}
aria-labelledby={ariaLabelledBy}
ref={listRef}
ref={mergedRef}
data-dividers={showDividers}
data-variant={variant}
{...restProps}
Expand Down
19 changes: 12 additions & 7 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react'
import React, {useCallback, useContext, useMemo, useEffect, useState, useRef} from 'react'
import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
import type {OverlayProps} from '../Overlay'
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks'
import {useProvidedStateOrCreate, useMenuKeyboardNavigation, useMergedRefs} from '../hooks'
import {Divider} from '../ActionList/Divider'
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import type {ButtonProps} from '../Button'
Expand Down Expand Up @@ -110,7 +110,9 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
)
const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined

const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const anchorRef = useRef<HTMLElement>(null)
const mergedRef = useMergedRefs(anchorRef, externalAnchorRef)

const anchorId = useId(menuButtonChildId)
let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null
// 🚨 Hack for good API!
Expand All @@ -130,7 +132,7 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
anchorChildren,
mergeAnchorHandlers({...anchorProps}, anchorChildren.props),
)
return React.cloneElement(child, {children: triggerButton, ref: anchorRef})
return React.cloneElement(child, {children: triggerButton, ref: combinedRef})
}
}
return null
Expand All @@ -149,7 +151,7 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
mergeAnchorHandlers({...anchorProps}, tooltipTrigger.props),
)
const tooltip = React.cloneElement(anchorChildren, {children: tooltipTriggerEl})
return React.cloneElement(child, {children: tooltip, ref: anchorRef})
return React.cloneElement(child, {children: tooltip, ref: combinedRef})
}
}
} else {
Expand Down Expand Up @@ -278,7 +280,7 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
// we typecast anchorRef as required instead of optional
// because we know that we're setting it in context in Menu
const {
anchorRef,
anchorRef: contextAnchorRef,
renderAnchor,
anchorId,
open,
Expand All @@ -287,6 +289,9 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
isSubmenu = false,
} = React.useContext(MenuContext) as MandateProps<MenuContextProps, 'anchorRef'>

const anchorRef = useRef<HTMLElement>(null)
const combinedAnchorRef = useMergedRefs(anchorRef, contextAnchorRef)

const containerRef = React.useRef<HTMLDivElement>(null)
const isNarrow = useResponsiveValue({narrow: true}, false)

Expand Down Expand Up @@ -328,7 +333,7 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({

return (
<AnchoredOverlay
anchorRef={anchorRef}
anchorRef={combinedAnchorRef}
renderAnchor={renderAnchor}
anchorId={anchorId}
open={open}
Expand Down
34 changes: 11 additions & 23 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type React from 'react'
import {useCallback, useEffect, type JSX} from 'react'
import {useCallback, useEffect, useRef, type JSX} from 'react'
import type {OverlayProps} from '../Overlay'
import Overlay from '../Overlay'
import type {FocusTrapHookSettings} from '../hooks/useFocusTrap'
import {useFocusTrap} from '../hooks/useFocusTrap'
import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
import {useFocusZone} from '../hooks/useFocusZone'
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
import {useAnchoredPosition, useMergedRefs, useRenderForcingRef} from '../hooks'
import {useId} from '../hooks/useId'
import type {AnchorPosition, PositionSettings} from '@primer/behaviors'
import {type ResponsiveValue} from '../hooks/useResponsiveValue'
Expand All @@ -27,7 +27,7 @@ interface AnchoredOverlayPropsWithAnchor {
/**
* An override to the internal ref that will be spread on to the renderAnchor
*/
anchorRef?: React.RefObject<HTMLElement | null>
anchorRef?: React.Ref<HTMLElement | null>

/**
* An override to the internal id that will be spread on to the renderAnchor
Expand All @@ -46,7 +46,7 @@ interface AnchoredOverlayPropsWithoutAnchor {
* An override to the internal renderAnchor ref that will be used to position the overlay.
* When renderAnchor is null this can be used to make an anchor that is detached from ActionMenu.
*/
anchorRef: React.RefObject<HTMLElement | null>
anchorRef: React.Ref<HTMLElement | null>
/**
* An override to the internal id that will be spread on to the renderAnchor
*/
Expand Down Expand Up @@ -160,8 +160,12 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
displayCloseButton = true,
closeButtonProps = defaultCloseButtonProps,
}) => {
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const anchorRef = useRef<HTMLElement>(null)
const mergedRef = useMergedRefs(anchorRef, externalAnchorRef)

const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
const combinedOverlayRef = useMergedRefs(updateOverlayRef, overlayProps?.ref)

const anchorId = useId(externalAnchorId)

const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose])
Expand Down Expand Up @@ -235,7 +239,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
<>
{renderAnchor &&
renderAnchor({
ref: anchorRef,
ref: combinedRef,
id: anchorId,
'aria-haspopup': 'true',
'aria-expanded': open,
Expand All @@ -261,12 +265,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
preventOverflow={preventOverflow}
data-component="AnchoredOverlay"
{...overlayProps}
ref={node => {
if (overlayProps?.ref) {
assignRef(overlayProps.ref, node)
}
updateOverlayRef(node)
}}
ref={combinedOverlayRef}
>
{showXIcon ? (
<div className={classes.ResponsiveCloseButtonContainer}>
Expand All @@ -293,15 +292,4 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
)
}

function assignRef<T>(
ref: React.MutableRefObject<T | null> | ((instance: T | null) => void) | null | undefined,
value: T | null,
) {
if (typeof ref === 'function') {
ref(value)
} else if (ref) {
ref.current = value
}
}

AnchoredOverlay.displayName = 'AnchoredOverlay'
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
10 changes: 5 additions & 5 deletions packages/react/src/ButtonGroup/ButtonGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, {type PropsWithChildren} from 'react'
import React, {useRef, type PropsWithChildren} from 'react'
import classes from './ButtonGroup.module.css'
import {clsx} from 'clsx'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import {useProvidedRefOrCreate} from '../hooks'
import {useMergedRefs} from '../hooks'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

export type ButtonGroupProps = PropsWithChildren<{
Expand All @@ -17,7 +17,8 @@ const ButtonGroup = React.forwardRef(function ButtonGroup(
forwardRef,
) {
const buttons = React.Children.map(children, (child, index) => <div key={index}>{child}</div>)
const buttonRef = useProvidedRefOrCreate(forwardRef as React.RefObject<HTMLDivElement | null>)
const buttonRef = useRef<HTMLDivElement>(null)
const mergedRef = useMergedRefs(buttonRef, forwardRef)

useFocusZone({
containerRef: buttonRef,
Expand All @@ -27,8 +28,7 @@ const ButtonGroup = React.forwardRef(function ButtonGroup(
})

return (
//@ts-expect-error it needs a non nullable ref
<BaseComponent ref={buttonRef} className={clsx(className, classes.ButtonGroup)} role={role} {...rest}>
<BaseComponent ref={mergedRef} className={clsx(className, classes.ButtonGroup)} role={role} {...rest}>
{buttons}
</BaseComponent>
)
Expand Down
18 changes: 13 additions & 5 deletions packages/react/src/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import {clsx} from 'clsx'
import {useProvidedRefOrCreate} from '../hooks'
import React, {useContext, useEffect, type ChangeEventHandler, type InputHTMLAttributes, type ReactElement} from 'react'
import {useMergedRefs} from '../hooks'
import React, {
useContext,
useEffect,
useRef,
type ChangeEventHandler,
type InputHTMLAttributes,
type ReactElement,
} from 'react'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import type {FormValidationStatus} from '../utils/types/FormValidationStatus'
import {CheckboxGroupContext} from '../CheckboxGroup/CheckboxGroupContext'
Expand Down Expand Up @@ -45,7 +52,8 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
ref,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ReactElement<any> => {
const checkboxRef = useProvidedRefOrCreate(ref as React.RefObject<HTMLInputElement>)
const checkboxRef = useRef<HTMLInputElement>(null)
const mergedRef = useMergedRefs(checkboxRef, ref)
const checkboxGroupContext = useContext(CheckboxGroupContext)
const handleOnChange: ChangeEventHandler<HTMLInputElement> = e => {
checkboxGroupContext.onChange && checkboxGroupContext.onChange(e)
Expand All @@ -54,7 +62,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
const inputProps = {
type: 'checkbox',
disabled,
ref: checkboxRef,
ref: combinedRef,
checked: indeterminate ? false : checked,
defaultChecked,
required,
Expand Down Expand Up @@ -84,7 +92,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
checkbox.setAttribute('aria-checked', checkbox.checked ? 'true' : 'false')
}
})
// @ts-expect-error inputProp needs a non nullable ref

return <input {...inputProps} className={clsx(className, sharedClasses.Input, classes.Checkbox)} />
},
)
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
8 changes: 4 additions & 4 deletions packages/react/src/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, {useCallback, useEffect, useRef, useState, type SyntheticEvent} from 'react'
import type {ButtonProps} from '../Button'
import {Button, IconButton} from '../Button'
import {useOnEscapePress, useProvidedRefOrCreate} from '../hooks'
import {useOnEscapePress} from '../hooks'
import {useFocusTrap} from '../hooks/useFocusTrap'
import {XIcon} from '@primer/octicons-react'
import {useFocusZone} from '../hooks/useFocusZone'
Expand Down Expand Up @@ -428,7 +428,8 @@ const Footer = React.forwardRef<HTMLDivElement, StyledFooterProps>(function Foot
Footer.displayName = 'Dialog.Footer'

const Buttons: React.FC<React.PropsWithChildren<{buttons: DialogButtonProps[]}>> = ({buttons}) => {
const autoFocusRef = useProvidedRefOrCreate<HTMLButtonElement>(buttons.find(button => button.autoFocus)?.ref)
const autoFocusRef = useRef<HTMLButtonElement>(null)
const mergedRef = useMergedRefs(autoFocusRef, buttons.find(button => button.autoFocus)?.ref)
let autoFocusCount = 0
const [hasRendered, setHasRendered] = useState(0)
useEffect(() => {
Expand All @@ -450,8 +451,7 @@ const Buttons: React.FC<React.PropsWithChildren<{buttons: DialogButtonProps[]}>>
{...buttonProps}
// 'normal' value is equivalent to 'default', this is used for backwards compatibility
variant={buttonType === 'normal' ? 'default' : buttonType}
// @ts-expect-error it needs a non nullable ref
ref={autoFocus && autoFocusCount === 0 ? (autoFocusCount++, autoFocusRef) : null}
ref={autoFocus && autoFocusCount === 0 ? (autoFocusCount++, combinedRef) : null}
>
{content}
</Button>
Expand Down
17 changes: 8 additions & 9 deletions packages/react/src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {ActionList, type ActionListProps} from '../ActionList'
import type {GroupedListProps, ListPropsBase, ItemInput, RenderItemFn} from './'
import {useFocusZone} from '../hooks/useFocusZone'
import {useId} from '../hooks/useId'
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import useScrollFlash from '../hooks/useScrollFlash'
import {VisuallyHidden} from '../VisuallyHidden'
Expand All @@ -22,6 +21,7 @@ import {isValidElementType} from 'react-is'
import {useAnnouncements} from './useAnnouncements'
import {clsx} from 'clsx'
import {useVirtualizer} from '@tanstack/react-virtual'
import {useMergedRefs} from '../hooks'

const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}

Expand Down Expand Up @@ -189,10 +189,11 @@ export function FilteredActionList({
const inputAndListContainerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)

const scrollContainerRef = useProvidedRefOrCreate<HTMLDivElement>(
providedScrollContainerRef as React.RefObject<HTMLDivElement>,
)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const combinedScrollContainerRef = useMergedRefs(scrollContainerRef, providedScrollContainerRef)

const inputRef = useRef<HTMLInputElement>(null)
const combinedInputRef = useMergedRefs(inputRef, providedInputRef)

const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex'
const [listContainerElement, setListContainerElement] = useState<HTMLUListElement | null>(null)
Expand Down Expand Up @@ -548,8 +549,7 @@ export function FilteredActionList({
<div ref={inputAndListContainerRef} className={clsx(className, classes.Root)} data-testid="filtered-action-list">
<div className={classes.Header}>
<TextInput
// @ts-expect-error it needs a non nullable ref
ref={inputRef}
ref={combinedInputRef}
block
width="auto"
color="fg.default"
Expand Down Expand Up @@ -585,8 +585,7 @@ export function FilteredActionList({
</label>
</div>
)}
{/* @ts-expect-error div needs a non nullable ref */}
<div ref={scrollContainerRef} className={classes.Container}>
<div ref={combinedScrollContainerRef} className={classes.Container}>
{getBodyContent()}
</div>
</div>
Expand Down
Loading
Loading