From dcae302ce2aadca345f0704111535e0d71440fdb Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 17 Mar 2026 16:26:29 +0000 Subject: [PATCH 01/18] Expose `useMergedRefs` and make ready for React 19 --- .../__snapshots__/exports.test.ts.snap | 1 + packages/react/src/hooks/index.ts | 1 + .../src/hooks/useMergedRefs.hookDocs.json | 22 +++++ packages/react/src/hooks/useMergedRefs.ts | 85 +++++++++++++++++++ packages/react/src/index.ts | 1 + .../react/src/internal/hooks/useMergedRefs.ts | 16 ---- 6 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 packages/react/src/hooks/useMergedRefs.hookDocs.json create mode 100644 packages/react/src/hooks/useMergedRefs.ts delete mode 100644 packages/react/src/internal/hooks/useMergedRefs.ts diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 77f3b55f2a2..cec11bdd137 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -218,6 +218,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useFormControlForwardedProps", "useId", "useIsomorphicLayoutEffect", + "useMergedRefs", "useOnEscapePress", "useOnOutsideClick", "useOpenAndCloseFocus", diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index d8259f5fd96..5938cacc2c0 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -16,3 +16,4 @@ export {useMnemonics} from './useMnemonics' export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef' export {useId} from './useId' export {useIsMacOS} from './useIsMacOS' +export {useMergedRefs} from './useMergedRefs' diff --git a/packages/react/src/hooks/useMergedRefs.hookDocs.json b/packages/react/src/hooks/useMergedRefs.hookDocs.json new file mode 100644 index 00000000000..840bfa50598 --- /dev/null +++ b/packages/react/src/hooks/useMergedRefs.hookDocs.json @@ -0,0 +1,22 @@ +{ + "name": "useMergedRefs", + "importPath": "@primer/react", + "stories": [], + "parameters": [ + { + "name": "refA", + "type": "Ref", + "required": true, + "description": "First ref to combine." + }, + { + "name": "refB", + "type": "Ref", + "required": true, + "description": "Second ref to combine." + } + ], + "returns": { + "type": "RefCallback" + } +} diff --git a/packages/react/src/hooks/useMergedRefs.ts b/packages/react/src/hooks/useMergedRefs.ts new file mode 100644 index 00000000000..4b3ed9ab661 --- /dev/null +++ b/packages/react/src/hooks/useMergedRefs.ts @@ -0,0 +1,85 @@ +import type {ForwardedRef, Ref as StandardRef, MutableRefObject} from 'react' +import {useCallback} from 'react' + +/** + * Combine two refs of matching type (typically an external or forwarded ref and an internal `useRef` object or + * callback ref). + * + * If you need to combine more than two refs (what are you doing?) just nest the hook: + * `useMergedRefs(refA, useMergedRefs(refB, refC))` + * + * @param refA First ref to merge. The order is not important. + * @param refB Second ref to merge. The order is not important. + * @returns A new ref which must be passed to the relevant child component. **Important**: do not pass `refA` or + * `refB` to the component! + * + * @example + * // React 18 + * const Example = forwardRef((props, forwardedRef) => { + * const ref = useRef(null) + * const combinedRef = useMergedRefs(forwardedRef, ref) + * + * return diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 10afa57107c..30955fdde78 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -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' @@ -22,6 +21,7 @@ import {isValidElementType} from 'react-is' import {useAnnouncements} from './useAnnouncements' import {clsx} from 'clsx' import {useVirtualizer} from '@tanstack/react-virtual' +import {useCombinedRefs} from '../hooks' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -189,10 +189,11 @@ export function FilteredActionList({ const inputAndListContainerRef = useRef(null) const listRef = useRef(null) - const scrollContainerRef = useProvidedRefOrCreate( - providedScrollContainerRef as React.RefObject, - ) - const inputRef = useProvidedRefOrCreate(providedInputRef) + const scrollContainerRef = useRef(null) + const combinedScrollContainerRef = useCombinedRefs(scrollContainerRef, providedScrollContainerRef) + + const inputRef = useRef(null) + const combinedInputRef = useCombinedRefs(inputRef, providedInputRef) const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' const [listContainerElement, setListContainerElement] = useState(null) @@ -548,8 +549,7 @@ export function FilteredActionList({
)} - {/* @ts-expect-error div needs a non nullable ref */} -
+
{getBodyContent()}
diff --git a/packages/react/src/PageHeader/PageHeader.tsx b/packages/react/src/PageHeader/PageHeader.tsx index 1acaadf06f3..2853b132ef1 100644 --- a/packages/react/src/PageHeader/PageHeader.tsx +++ b/packages/react/src/PageHeader/PageHeader.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react' +import React, {useEffect, useRef} from 'react' import type {ResponsiveValue} from '../hooks/useResponsiveValue' import {isResponsiveValue} from '../hooks/useResponsiveValue' import Heading from '../Heading' @@ -10,7 +10,7 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {areAllValuesTheSame, haveRegularAndWideSameValue} from '../utils/getBreakpointDeclarations' import {warning} from '../utils/warning' -import {useProvidedRefOrCreate} from '../hooks' +import {useCombinedRefs} from '../hooks' import type {AriaRole, FCWithSlotMarker} from '../utils/types' import {clsx} from 'clsx' @@ -49,7 +49,8 @@ export type PageHeaderProps = { const Root = React.forwardRef>( ({children, className, as: BaseComponent = 'div', 'aria-label': ariaLabel, role, hasBorder}, forwardedRef) => { - const rootRef = useProvidedRefOrCreate(forwardedRef as React.RefObject) + const rootRef = useRef(null) + const combinedRef = useCombinedRefs(rootRef, forwardedRef) const isInteractive = (element: HTMLElement) => { return ( @@ -105,7 +106,7 @@ const Root = React.forwardRef>( ({children, className, hidden = false, variant = 'medium'}, forwardedRef) => { - const titleAreaRef = useProvidedRefOrCreate(forwardedRef as React.RefObject) return (
(null) + const combinedAnchorRef = useCombinedRefs(anchorRef, externalAnchorRef) + const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( (gesture: Parameters>[0]) => onOpenChange(true, gesture), [onOpenChange], @@ -860,7 +862,7 @@ function Panel({ <> ( ref, ) => { const [isInputFocused, setIsInputFocused] = useState(false) - const inputRef = useProvidedRefOrCreate(ref as React.RefObject) + const inputRef = useRef(null) + const combinedRef = useCombinedRefs(inputRef, ref) const [characterCount, setCharacterCount] = useState('') const [isOverLimit, setIsOverLimit] = useState(false) const [screenReaderMessage, setScreenReaderMessage] = useState('') @@ -258,8 +259,7 @@ const TextInput = React.forwardRef( {typeof LeadingVisual !== 'string' && isValidElementType(LeadingVisual) ? : LeadingVisual} & { - ref?: React.RefObject + ref?: React.Ref } // map tooltip direction to anchoredPosition props @@ -124,7 +124,8 @@ export const Tooltip: ForwardRefExoticComponent< ) => { const tooltipId = useId(id) const child = Children.only(children) - const triggerRef = useProvidedRefOrCreate(forwardedRef as React.RefObject) + const triggerRef = useRef(null) + const combinedTriggerRef = useCombinedRefs(triggerRef, forwardedRef) const tooltipElRef = useRef(null) const [calculatedDirection, setCalculatedDirection] = useState(direction) @@ -279,8 +280,7 @@ export const Tooltip: ForwardRefExoticComponent< {React.isValidElement(child) && // eslint-disable-next-line react-hooks/refs React.cloneElement(child as React.ReactElement, { - // @ts-expect-error it needs a non nullable ref - ref: triggerRef, + ref: combinedTriggerRef, // If it is a type description, we use tooltip to describe the trigger 'aria-describedby': (() => { // If tooltip is not a description type, keep the original aria-describedby diff --git a/packages/react/src/deprecated/ActionMenu.tsx b/packages/react/src/deprecated/ActionMenu.tsx index fa0cf1de949..5067ae6b28c 100644 --- a/packages/react/src/deprecated/ActionMenu.tsx +++ b/packages/react/src/deprecated/ActionMenu.tsx @@ -6,11 +6,11 @@ import {Divider} from './ActionList/Divider' import type {ButtonProps} from '../Button' import {Button} from '../Button' import type React from 'react' -import {useCallback, useMemo, type JSX} from 'react' +import {useCallback, useMemo, useRef, type JSX} from 'react' import {AnchoredOverlay} from '../AnchoredOverlay' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import type {OverlayProps} from '../Overlay' -import {useProvidedRefOrCreate} from '../hooks' +import {useCombinedRefs} from '../hooks' import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' interface ActionMenuBaseProps extends Partial>, ListPropsBase { @@ -60,7 +60,8 @@ const ActionMenuBase = ({ ...listProps }: ActionMenuProps): JSX.Element => { const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, setOpen, false) - const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + const anchorRef = useRef(null) + const combinedRef = useCombinedRefs(anchorRef, externalAnchorRef) const onOpen = useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) const onClose = useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) @@ -96,7 +97,7 @@ const ActionMenuBase = ({ return ( = ({ // with additional props for accessibility // eslint-disable-next-line @typescript-eslint/no-explicit-any let Anchor: React.ReactElement | undefined - const anchorRef = useProvidedRefOrCreate(providedAnchorRef) + + const anchorRef = useRef(null) + const combinedRef = useCombinedRefs(providedAnchorRef, anchorRef) const onAnchorClick = () => { if (!internalOpen) setInternalOpen(true) @@ -130,7 +132,7 @@ const Panel: React.FC = ({ // eslint-disable-next-line react-hooks/immutability Anchor = React.cloneElement(child, { // @ts-ignore TODO - ref: anchorRef, + ref: combinedRef, onClick: child.props.onClick || onAnchorClick, 'aria-haspopup': true, 'aria-expanded': internalOpen, diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index 69c891b5350..137d064fa5e 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -3,13 +3,14 @@ import React, { useContext, useId, useMemo, + useRef, type AriaAttributes, type ElementRef, type PropsWithChildren, } from 'react' import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import {useControllableState} from '../../hooks/useControllableState' -import {useProvidedRefOrCreate} from '../../hooks' +import {useCombinedRefs} from '../../hooks' /** * Props to be used when the Tabs component's state is controlled by the parent @@ -114,13 +115,14 @@ function useTabList( 'aria-orientation': AriaAttributes['aria-orientation'] 'aria-label': AriaAttributes['aria-label'] 'aria-labelledby': AriaAttributes['aria-labelledby'] - ref: React.RefObject + ref: React.Ref role: 'tablist' } } { const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props - const ref = useProvidedRefOrCreate(props.ref) + const ref = useRef(null) + const combinedRef = useCombinedRefs(ref, props.ref) const onKeyDown = (event: React.KeyboardEvent) => { const {current: tablist} = ref @@ -172,7 +174,7 @@ function useTabList( return { tabListProps: { - ref, + ref: combinedRef, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation ?? 'horizontal', @@ -186,7 +188,6 @@ function TabList({children, ...rest}: TabListProps) { const {tabListProps} = useTabList(rest) return ( - // @ts-expect-error it needs a non nullable ref
{children}
diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 32777aad1d7..b86e6f5ebdb 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -1,9 +1,9 @@ import React from 'react' import {getAnchoredPosition} from '@primer/behaviors' import type {AnchorPosition, PositionSettings} from '@primer/behaviors' -import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' import {useResizeObserver} from './useResizeObserver' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' export interface AnchoredPositionHookSettings extends Partial { floatingElementRef?: React.RefObject diff --git a/packages/react/src/hooks/useFocusTrap.ts b/packages/react/src/hooks/useFocusTrap.ts index 70b80072d51..ed09cb36f8b 100644 --- a/packages/react/src/hooks/useFocusTrap.ts +++ b/packages/react/src/hooks/useFocusTrap.ts @@ -1,7 +1,7 @@ import React from 'react' import {focusTrap} from '@primer/behaviors' -import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' import {useOnOutsideClick} from './useOnOutsideClick' +import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' export interface FocusTrapHookSettings { /** @@ -53,8 +53,10 @@ export function useFocusTrap( dependencies: React.DependencyList = [], ): {containerRef: React.RefObject; initialFocusRef: React.RefObject} { const [outsideClicked, setOutsideClicked] = React.useState(false) + const containerRef = useProvidedRefOrCreate(settings?.containerRef) const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef) + const disabled = settings?.disabled const abortController = React.useRef() const previousFocusedElement = React.useRef(null) From 3b478e3cbffb70d60b0155e69f985cacfe4050f0 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 9 Mar 2026 15:31:10 +0000 Subject: [PATCH 07/18] Migrate `AnchoredOverlay` anchor ref --- packages/react/src/ActionMenu/ActionMenu.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 62cb5578cd0..3b2cefe8271 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -280,7 +280,7 @@ const Overlay: FCWithSlotMarker> = ({ // 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, @@ -289,6 +289,9 @@ const Overlay: FCWithSlotMarker> = ({ isSubmenu = false, } = React.useContext(MenuContext) as MandateProps + const anchorRef = useRef(null) + const combinedAnchorRef = useCombinedRefs(anchorRef, contextAnchorRef) + const containerRef = React.useRef(null) const isNarrow = useResponsiveValue({narrow: true}, false) @@ -330,7 +333,7 @@ const Overlay: FCWithSlotMarker> = ({ return ( Date: Mon, 9 Mar 2026 15:46:41 +0000 Subject: [PATCH 08/18] Drop more ts-expect-error! --- packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx index ebe9a81a5aa..df2d7a518e4 100644 --- a/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx @@ -17,7 +17,6 @@ const CustomTabList = (props: React.PropsWithChildren) => { return (
- {/* @ts-expect-error it needs a non nullable ref */} {props.children}
) From ec31af7839724fe47b001a359b2c4e399bf3402b Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 9 Mar 2026 15:47:07 +0000 Subject: [PATCH 09/18] Revert behaviors docs --- contributor-docs/behaviors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contributor-docs/behaviors.md b/contributor-docs/behaviors.md index d8a45bb428c..9d6995c2e70 100644 --- a/contributor-docs/behaviors.md +++ b/contributor-docs/behaviors.md @@ -44,7 +44,7 @@ Generic behaviors provide functionality that is not specific to any single compo ### Examples -- `useCombinedRefs` +- `useProvidedRefOrCreate` - `usePosition` - `useClickAway` - `useTypeAhead` @@ -123,7 +123,7 @@ There are no restrictions on return values of generic behavior hooks. In fact, s - Often, a behavior will need to act on a real DOM element. - In this case, the hook should return a ref as part of the returned props for that element. The ref will get spread onto the element, giving the ref access to it. -- Whenever you need a ref, it must be accepted as an optional setting to the hook. +- Whenever you need a ref, it must be accepted as an optional setting to the hook. The hook then uses the `useProvidedRefOrCreate` hook to resolve a usable ref. Remember to return the resulting ref from the hook. ## Testing behaviors From 7bf1ce12b898d84242ba36aa97c25b1184e4eb4e Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 10 Mar 2026 12:32:50 -0400 Subject: [PATCH 10/18] Update packages/react/src/Checkbox/Checkbox.tsx --- packages/react/src/Checkbox/Checkbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/Checkbox/Checkbox.tsx b/packages/react/src/Checkbox/Checkbox.tsx index 48294ed72e4..3bd7307b51b 100644 --- a/packages/react/src/Checkbox/Checkbox.tsx +++ b/packages/react/src/Checkbox/Checkbox.tsx @@ -53,7 +53,7 @@ const Checkbox = React.forwardRef( // eslint-disable-next-line @typescript-eslint/no-explicit-any ): ReactElement => { const checkboxRef = useRef(null) - const combinedRef = useCombinedRefs(checkboxRef, ref as React.RefObject) + const combinedRef = useCombinedRefs(checkboxRef, ref) const checkboxGroupContext = useContext(CheckboxGroupContext) const handleOnChange: ChangeEventHandler = e => { checkboxGroupContext.onChange && checkboxGroupContext.onChange(e) From 006cc27654a941d7f91de011873ec25cd49238dc Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 17 Mar 2026 17:00:55 +0000 Subject: [PATCH 11/18] Update useCombinedRefs to useMergedRefs --- packages/react/src/ActionList/List.tsx | 6 +++--- packages/react/src/ActionMenu/ActionMenu.tsx | 6 +++--- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 6 +++--- packages/react/src/ButtonGroup/ButtonGroup.tsx | 6 +++--- packages/react/src/Checkbox/Checkbox.tsx | 4 ++-- packages/react/src/Dialog/Dialog.tsx | 2 +- .../react/src/FilteredActionList/FilteredActionList.tsx | 6 +++--- packages/react/src/PageHeader/PageHeader.tsx | 6 +++--- packages/react/src/SelectPanel/SelectPanel.tsx | 4 ++-- packages/react/src/TextInput/TextInput.tsx | 6 +++--- packages/react/src/TooltipV2/Tooltip.tsx | 4 ++-- packages/react/src/deprecated/ActionMenu.tsx | 6 +++--- .../react/src/experimental/SelectPanel2/SelectPanel.tsx | 4 ++-- packages/react/src/experimental/Tabs/Tabs.tsx | 4 ++-- packages/react/src/hooks/__tests__/useMergedRefs.test.tsx | 4 ++-- packages/react/src/hooks/useMergedRefs.ts | 8 ++++---- 16 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index fdf152b3fce..53cf3ede473 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -5,7 +5,7 @@ import {useSlots} from '../hooks/useSlots' import {Heading} from './Heading' import {useId} from '../hooks/useId' import {ListContext, type ActionListProps} from './shared' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' import {clsx} from 'clsx' import classes from './ActionList.module.css' @@ -42,7 +42,7 @@ const UnwrappedList = ( const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy const listRole = role || listRoleFromContainer const listRef = useRef(null) - const combinedRef = useCombinedRefs(forwardedRef, listRef) + const mergedRef = useMergedRefs(forwardedRef, listRef) let enableFocusZone = false if (enableFocusZoneFromContainer !== undefined) enableFocusZone = enableFocusZoneFromContainer @@ -74,7 +74,7 @@ const UnwrappedList = ( className={clsx(classes.ActionList, className)} role={listRole} aria-labelledby={ariaLabelledBy} - ref={combinedRef} + ref={mergedRef} data-dividers={showDividers} data-variant={variant} {...restProps} diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 3b2cefe8271..0bf371af037 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -3,7 +3,7 @@ import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react' import type {AnchoredOverlayProps} from '../AnchoredOverlay' import {AnchoredOverlay} from '../AnchoredOverlay' import type {OverlayProps} from '../Overlay' -import {useProvidedStateOrCreate, useMenuKeyboardNavigation, useCombinedRefs} from '../hooks' +import {useProvidedStateOrCreate, useMenuKeyboardNavigation, useMergedRefs} from '../hooks' import {Divider} from '../ActionList/Divider' import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' import type {ButtonProps} from '../Button' @@ -111,7 +111,7 @@ const Menu: FCWithSlotMarker> = ({ const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined const anchorRef = useRef(null) - const combinedRef = useCombinedRefs(anchorRef, externalAnchorRef) + const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) const anchorId = useId(menuButtonChildId) let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null @@ -290,7 +290,7 @@ const Overlay: FCWithSlotMarker> = ({ } = React.useContext(MenuContext) as MandateProps const anchorRef = useRef(null) - const combinedAnchorRef = useCombinedRefs(anchorRef, contextAnchorRef) + const combinedAnchorRef = useMergedRefs(anchorRef, contextAnchorRef) const containerRef = React.useRef(null) const isNarrow = useResponsiveValue({narrow: true}, false) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index d9cfa87e089..11a80529171 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -6,7 +6,7 @@ import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' import {useFocusTrap} from '../hooks/useFocusTrap' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useFocusZone} from '../hooks/useFocusZone' -import {useAnchoredPosition, useCombinedRefs, 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' @@ -161,10 +161,10 @@ export const AnchoredOverlay: React.FC { const anchorRef = useRef(null) - const combinedRef = useCombinedRefs(anchorRef, externalAnchorRef) + const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) const [overlayRef, updateOverlayRef] = useRenderForcingRef() - const combinedOverlayRef = useCombinedRefs(updateOverlayRef, overlayProps?.ref) + const combinedOverlayRef = useMergedRefs(updateOverlayRef, overlayProps?.ref) const anchorId = useId(externalAnchorId) diff --git a/packages/react/src/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ButtonGroup/ButtonGroup.tsx index 27cefdab038..1dfe82dc0f4 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.tsx +++ b/packages/react/src/ButtonGroup/ButtonGroup.tsx @@ -2,7 +2,7 @@ import React, {useRef, type PropsWithChildren} from 'react' import classes from './ButtonGroup.module.css' import {clsx} from 'clsx' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' export type ButtonGroupProps = PropsWithChildren<{ @@ -18,7 +18,7 @@ const ButtonGroup = React.forwardRef(function ButtonGroup( ) { const buttons = React.Children.map(children, (child, index) =>
{child}
) const buttonRef = useRef(null) - const combinedRef = useCombinedRefs(buttonRef, forwardRef) + const mergedRef = useMergedRefs(buttonRef, forwardRef) useFocusZone({ containerRef: buttonRef, @@ -28,7 +28,7 @@ const ButtonGroup = React.forwardRef(function ButtonGroup( }) return ( - + {buttons} ) diff --git a/packages/react/src/Checkbox/Checkbox.tsx b/packages/react/src/Checkbox/Checkbox.tsx index 3bd7307b51b..8b0805b2def 100644 --- a/packages/react/src/Checkbox/Checkbox.tsx +++ b/packages/react/src/Checkbox/Checkbox.tsx @@ -1,5 +1,5 @@ import {clsx} from 'clsx' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' import React, { useContext, useEffect, @@ -53,7 +53,7 @@ const Checkbox = React.forwardRef( // eslint-disable-next-line @typescript-eslint/no-explicit-any ): ReactElement => { const checkboxRef = useRef(null) - const combinedRef = useCombinedRefs(checkboxRef, ref) + const mergedRef = useMergedRefs(checkboxRef, ref) const checkboxGroupContext = useContext(CheckboxGroupContext) const handleOnChange: ChangeEventHandler = e => { checkboxGroupContext.onChange && checkboxGroupContext.onChange(e) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 8fa272a0b02..81cd3b21b23 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -429,7 +429,7 @@ Footer.displayName = 'Dialog.Footer' const Buttons: React.FC> = ({buttons}) => { const autoFocusRef = useRef(null) - const combinedRef = useCombinedRefs(autoFocusRef, buttons.find(button => button.autoFocus)?.ref) + const mergedRef = useMergedRefs(autoFocusRef, buttons.find(button => button.autoFocus)?.ref) let autoFocusCount = 0 const [hasRendered, setHasRendered] = useState(0) useEffect(() => { diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 30955fdde78..94d4481599e 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -21,7 +21,7 @@ import {isValidElementType} from 'react-is' import {useAnnouncements} from './useAnnouncements' import {clsx} from 'clsx' import {useVirtualizer} from '@tanstack/react-virtual' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -190,10 +190,10 @@ export function FilteredActionList({ const listRef = useRef(null) const scrollContainerRef = useRef(null) - const combinedScrollContainerRef = useCombinedRefs(scrollContainerRef, providedScrollContainerRef) + const combinedScrollContainerRef = useMergedRefs(scrollContainerRef, providedScrollContainerRef) const inputRef = useRef(null) - const combinedInputRef = useCombinedRefs(inputRef, providedInputRef) + const combinedInputRef = useMergedRefs(inputRef, providedInputRef) const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' const [listContainerElement, setListContainerElement] = useState(null) diff --git a/packages/react/src/PageHeader/PageHeader.tsx b/packages/react/src/PageHeader/PageHeader.tsx index 2853b132ef1..fa3095f86aa 100644 --- a/packages/react/src/PageHeader/PageHeader.tsx +++ b/packages/react/src/PageHeader/PageHeader.tsx @@ -10,7 +10,7 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {areAllValuesTheSame, haveRegularAndWideSameValue} from '../utils/getBreakpointDeclarations' import {warning} from '../utils/warning' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' import type {AriaRole, FCWithSlotMarker} from '../utils/types' import {clsx} from 'clsx' @@ -50,7 +50,7 @@ export type PageHeaderProps = { const Root = React.forwardRef>( ({children, className, as: BaseComponent = 'div', 'aria-label': ariaLabel, role, hasBorder}, forwardedRef) => { const rootRef = useRef(null) - const combinedRef = useCombinedRefs(rootRef, forwardedRef) + const mergedRef = useMergedRefs(rootRef, forwardedRef) const isInteractive = (element: HTMLElement) => { return ( @@ -106,7 +106,7 @@ const Root = React.forwardRef(null) - const combinedAnchorRef = useCombinedRefs(anchorRef, externalAnchorRef) + const combinedAnchorRef = useMergedRefs(anchorRef, externalAnchorRef) const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( (gesture: Parameters>[0]) => onOpenChange(true, gesture), diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index 7c8585e7d73..eaba650e882 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -15,7 +15,7 @@ import UnstyledTextInput from '../internal/components/UnstyledTextInput' import VisuallyHidden from '../_VisuallyHidden' import {CharacterCounter} from '../utils/character-counter' import Text from '../Text' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' export type TextInputNonPassthroughProps = { /** @deprecated Use `leadingVisual` or `trailingVisual` prop instead */ @@ -104,7 +104,7 @@ const TextInput = React.forwardRef( ) => { const [isInputFocused, setIsInputFocused] = useState(false) const inputRef = useRef(null) - const combinedRef = useCombinedRefs(inputRef, ref) + const mergedRef = useMergedRefs(inputRef, ref) const [characterCount, setCharacterCount] = useState('') const [isOverLimit, setIsOverLimit] = useState(false) const [screenReaderMessage, setScreenReaderMessage] = useState('') @@ -259,7 +259,7 @@ const TextInput = React.forwardRef( {typeof LeadingVisual !== 'string' && isValidElementType(LeadingVisual) ? : LeadingVisual} (null) - const combinedTriggerRef = useCombinedRefs(triggerRef, forwardedRef) + const combinedTriggerRef = useMergedRefs(triggerRef, forwardedRef) const tooltipElRef = useRef(null) const [calculatedDirection, setCalculatedDirection] = useState(direction) diff --git a/packages/react/src/deprecated/ActionMenu.tsx b/packages/react/src/deprecated/ActionMenu.tsx index 5067ae6b28c..65343d1318b 100644 --- a/packages/react/src/deprecated/ActionMenu.tsx +++ b/packages/react/src/deprecated/ActionMenu.tsx @@ -10,7 +10,7 @@ import {useCallback, useMemo, useRef, type JSX} from 'react' import {AnchoredOverlay} from '../AnchoredOverlay' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import type {OverlayProps} from '../Overlay' -import {useCombinedRefs} from '../hooks' +import {useMergedRefs} from '../hooks' import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' interface ActionMenuBaseProps extends Partial>, ListPropsBase { @@ -61,7 +61,7 @@ const ActionMenuBase = ({ }: ActionMenuProps): JSX.Element => { const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, setOpen, false) const anchorRef = useRef(null) - const combinedRef = useCombinedRefs(anchorRef, externalAnchorRef) + const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) const onOpen = useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) const onClose = useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) @@ -97,7 +97,7 @@ const ActionMenuBase = ({ return ( = ({ let Anchor: React.ReactElement | undefined const anchorRef = useRef(null) - const combinedRef = useCombinedRefs(providedAnchorRef, anchorRef) + const mergedRef = useMergedRefs(providedAnchorRef, anchorRef) const onAnchorClick = () => { if (!internalOpen) setInternalOpen(true) diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index 137d064fa5e..ad171ea4c41 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -10,7 +10,7 @@ import React, { } from 'react' import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import {useControllableState} from '../../hooks/useControllableState' -import {useCombinedRefs} from '../../hooks' +import {useMergedRefs} from '../../hooks' /** * Props to be used when the Tabs component's state is controlled by the parent @@ -122,7 +122,7 @@ function useTabList( const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props const ref = useRef(null) - const combinedRef = useCombinedRefs(ref, props.ref) + const mergedRef = useMergedRefs(ref, props.ref) const onKeyDown = (event: React.KeyboardEvent) => { const {current: tablist} = ref diff --git a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx index 329dd9e8908..43ef7f8d706 100644 --- a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx +++ b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx @@ -8,9 +8,9 @@ type InputOrButtonRef = RefObject const Component = forwardRef(({asButton}, forwardedRef) => { const ref: InputOrButtonRef = React.useRef(null) - const combinedRef = useMergedRefs(forwardedRef, ref) + const mergedRef = useMergedRefs(forwardedRef, ref) - return asButton ? diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx index e7bb7b8ffc7..05d0e3c98f7 100644 --- a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx +++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx @@ -131,8 +131,7 @@ const Panel: React.FC = ({ if (React.isValidElement(child) && (child.type === SelectPanelButton || isSlot(child, SelectPanelButton))) { // eslint-disable-next-line react-hooks/immutability Anchor = React.cloneElement(child, { - // @ts-ignore TODO - ref: combinedRef, + ref: mergedRef, onClick: child.props.onClick || onAnchorClick, 'aria-haspopup': true, 'aria-expanded': internalOpen, diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index ad171ea4c41..50400ad435a 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -174,7 +174,7 @@ function useTabList( return { tabListProps: { - ref: combinedRef, + ref: mergedRef, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation ?? 'horizontal', From a09ae22994f4ce84e4c1e70f243b706c5d969283 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Mar 2026 19:01:37 +0000 Subject: [PATCH 14/18] anchorref > anchorRef --- packages/react/src/deprecated/ActionMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/deprecated/ActionMenu.tsx b/packages/react/src/deprecated/ActionMenu.tsx index 65343d1318b..43cf9371151 100644 --- a/packages/react/src/deprecated/ActionMenu.tsx +++ b/packages/react/src/deprecated/ActionMenu.tsx @@ -97,7 +97,7 @@ const ActionMenuBase = ({ return ( Date: Tue, 24 Mar 2026 19:23:36 +0000 Subject: [PATCH 15/18] Revert some files because of a valid use case --- packages/react/src/ActionMenu/ActionMenu.tsx | 19 +++++++------------ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 13 +++++-------- .../react/src/SelectPanel/SelectPanel.tsx | 8 +++----- packages/react/src/deprecated/ActionMenu.tsx | 9 ++++----- .../experimental/SelectPanel2/SelectPanel.tsx | 11 +++++------ .../react/src/hooks/useAnchoredPosition.ts | 2 +- packages/react/src/hooks/useFocusTrap.ts | 4 +--- 7 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 507eaaf4c25..429d1a5d0b0 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -1,10 +1,10 @@ -import React, {useCallback, useContext, useMemo, useEffect, useState, useRef} from 'react' +import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react' import {clsx} from 'clsx' import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react' import type {AnchoredOverlayProps} from '../AnchoredOverlay' import {AnchoredOverlay} from '../AnchoredOverlay' import type {OverlayProps} from '../Overlay' -import {useProvidedStateOrCreate, useMenuKeyboardNavigation, useMergedRefs} from '../hooks' +import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks' import {Divider} from '../ActionList/Divider' import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' import type {ButtonProps} from '../Button' @@ -115,9 +115,7 @@ const Menu: FCWithSlotMarker> = ({ ) const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined - const anchorRef = useRef(null) - const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) - + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const anchorId = useId(menuButtonChildId) let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null // 🚨 Hack for good API! @@ -137,7 +135,7 @@ const Menu: FCWithSlotMarker> = ({ anchorChildren, mergeAnchorHandlers({...anchorProps}, anchorChildren.props), ) - return React.cloneElement(child, {children: triggerButton, ref: mergedRef}) + return React.cloneElement(child, {children: triggerButton, ref: anchorRef}) } } return null @@ -156,7 +154,7 @@ const Menu: FCWithSlotMarker> = ({ mergeAnchorHandlers({...anchorProps}, tooltipTrigger.props), ) const tooltip = React.cloneElement(anchorChildren, {children: tooltipTriggerEl}) - return React.cloneElement(child, {children: tooltip, ref: mergedRef}) + return React.cloneElement(child, {children: tooltip, ref: anchorRef}) } } } else { @@ -290,7 +288,7 @@ const Overlay: FCWithSlotMarker> = ({ // we typecast anchorRef as required instead of optional // because we know that we're setting it in context in Menu const { - anchorRef: contextAnchorRef, + anchorRef, renderAnchor, anchorId, open, @@ -299,9 +297,6 @@ const Overlay: FCWithSlotMarker> = ({ isSubmenu = false, } = React.useContext(MenuContext) as MandateProps - const anchorRef = useRef(null) - const mergedAnchorRef = useMergedRefs(anchorRef, contextAnchorRef) - const containerRef = React.useRef(null) const isNarrow = useResponsiveValue({narrow: true}, false) @@ -345,7 +340,7 @@ const Overlay: FCWithSlotMarker> = ({ return ( + anchorRef?: React.RefObject /** * An override to the internal id that will be spread on to the renderAnchor @@ -47,7 +47,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.Ref + anchorRef: React.RefObject /** * An override to the internal id that will be spread on to the renderAnchor */ @@ -173,12 +173,9 @@ export const AnchoredOverlay: React.FC { const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning') - const anchorRef = useRef(null) - const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) - + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const mergedOverlayRef = useMergedRefs(updateOverlayRef, overlayProps?.ref) - const anchorId = useId(externalAnchorId) const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose]) @@ -261,7 +258,7 @@ export const AnchoredOverlay: React.FC {renderAnchor && renderAnchor({ - ref: mergedRef, + ref: anchorRef, id: anchorId, 'aria-haspopup': 'true', 'aria-expanded': open, diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 061abb5c5d5..82cd0812d1b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -12,7 +12,7 @@ import type {ItemProps, ItemInput} from './' import {SelectPanelMessage} from './SelectPanelMessage' import {Button, IconButton, LinkButton} from '../Button' -import {useMergedRefs} from '../hooks' +import {useProvidedRefOrCreate} from '../hooks' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' @@ -513,9 +513,7 @@ function Panel({ } }, [notice, open]) - const anchorRef = useRef(null) - const mergedAnchorRef = useMergedRefs(anchorRef, externalAnchorRef) - + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( (gesture: Parameters>[0]) => onOpenChange(true, gesture), [onOpenChange], @@ -862,7 +860,7 @@ function Panel({ <> >, ListPropsBase { @@ -60,8 +60,7 @@ const ActionMenuBase = ({ ...listProps }: ActionMenuProps): JSX.Element => { const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, setOpen, false) - const anchorRef = useRef(null) - const mergedRef = useMergedRefs(anchorRef, externalAnchorRef) + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const onOpen = useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) const onClose = useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) @@ -97,7 +96,7 @@ const ActionMenuBase = ({ return ( = ({ // with additional props for accessibility // eslint-disable-next-line @typescript-eslint/no-explicit-any let Anchor: React.ReactElement | undefined - - const anchorRef = useRef(null) - const mergedRef = useMergedRefs(providedAnchorRef, anchorRef) + const anchorRef = useProvidedRefOrCreate(providedAnchorRef) const onAnchorClick = () => { if (!internalOpen) setInternalOpen(true) @@ -131,7 +129,8 @@ const Panel: React.FC = ({ if (React.isValidElement(child) && (child.type === SelectPanelButton || isSlot(child, SelectPanelButton))) { // eslint-disable-next-line react-hooks/immutability Anchor = React.cloneElement(child, { - ref: mergedRef, + // @ts-ignore TODO + ref: anchorRef, onClick: child.props.onClick || onAnchorClick, 'aria-haspopup': true, 'aria-expanded': internalOpen, diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index b86e6f5ebdb..32777aad1d7 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -1,9 +1,9 @@ import React from 'react' import {getAnchoredPosition} from '@primer/behaviors' import type {AnchorPosition, PositionSettings} from '@primer/behaviors' +import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' import {useResizeObserver} from './useResizeObserver' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' -import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' export interface AnchoredPositionHookSettings extends Partial { floatingElementRef?: React.RefObject diff --git a/packages/react/src/hooks/useFocusTrap.ts b/packages/react/src/hooks/useFocusTrap.ts index ed09cb36f8b..70b80072d51 100644 --- a/packages/react/src/hooks/useFocusTrap.ts +++ b/packages/react/src/hooks/useFocusTrap.ts @@ -1,7 +1,7 @@ import React from 'react' import {focusTrap} from '@primer/behaviors' -import {useOnOutsideClick} from './useOnOutsideClick' import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' +import {useOnOutsideClick} from './useOnOutsideClick' export interface FocusTrapHookSettings { /** @@ -53,10 +53,8 @@ export function useFocusTrap( dependencies: React.DependencyList = [], ): {containerRef: React.RefObject; initialFocusRef: React.RefObject} { const [outsideClicked, setOutsideClicked] = React.useState(false) - const containerRef = useProvidedRefOrCreate(settings?.containerRef) const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef) - const disabled = settings?.disabled const abortController = React.useRef() const previousFocusedElement = React.useRef(null) From dda43826c604d076269292b3e5886153d65f7c11 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Mar 2026 19:30:28 +0000 Subject: [PATCH 16/18] Update `useProvidedRefOrCreate` to remove deprecation comment --- .../react/src/hooks/useProvidedRefOrCreate.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/react/src/hooks/useProvidedRefOrCreate.ts b/packages/react/src/hooks/useProvidedRefOrCreate.ts index bec68d8dabd..99f99e8d64f 100644 --- a/packages/react/src/hooks/useProvidedRefOrCreate.ts +++ b/packages/react/src/hooks/useProvidedRefOrCreate.ts @@ -1,5 +1,9 @@ import React from 'react' +// used in doc comment +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type {useMergedRefs} from './useMergedRefs' + /** * There are some situations where we only want to create a new ref if one is not provided to a component * or hook as a prop. However, due to the `rules-of-hooks`, we cannot conditionally make a call to `React.useRef` @@ -8,17 +12,14 @@ import React from 'react' * @param providedRef The ref to use - if undefined, will use the ref from a call to React.useRef * @type TRef The type of the RefObject which should be created. * - * @deprecated This hook is incompatible with forwarded callback refs. Prefer `useMergedRefs` with an internally - * created ref instead. - * - * ```diff - * - const ref = useProvidedRefOrCreate(forwardedRef as RefObject<...>) - * + const ref = useRef(null) - * + const mergedRef = useMergedRefs(forwardedRef, ref) + * @note This hook is overly restrictive in that it forces the provided ref to be a ref object rather than a callback. + * In particular, it should **not** be used to merge internal refs with forwarded refs. For this, use + * {@linkcode useMergedRefs} instead. * - * - return
- * + return
- * ``` + * The primary valid use case for this hook is for when the consumer is using the provided ref to point to an external + * element, like an externally rendered anchor. This is 'backwards' from forwarded refs with respect to data flow; the + * consumer is passing a reference to an external element, rather than passing a handle to obtain a reference to an + * internal element. */ export function useProvidedRefOrCreate(providedRef?: React.RefObject): React.RefObject { const createdRef = React.useRef(null) From f86b058c5da2cab105c54d97a83308a1c44e152d Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Mar 2026 19:41:23 +0000 Subject: [PATCH 17/18] Don't return a callback ref in React 18- --- packages/react/src/hooks/useMergedRefs.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/useMergedRefs.ts b/packages/react/src/hooks/useMergedRefs.ts index a80b63f8e72..bfc16917965 100644 --- a/packages/react/src/hooks/useMergedRefs.ts +++ b/packages/react/src/hooks/useMergedRefs.ts @@ -1,5 +1,7 @@ import type {ForwardedRef, Ref as StandardRef, MutableRefObject} from 'react' -import {useCallback} from 'react' +import {useCallback, version} from 'react' + +const majorReactVersion = parseInt(version.split('.')[0] ?? '18', 10) /** * Combine two refs of matching type (typically an external or forwarded ref and an internal `useRef` object or @@ -37,8 +39,10 @@ export function useMergedRefs(refA: Ref, refB: Ref) { const cleanupA = setRef(refA, value) const cleanupB = setRef(refB, value) - // Only works in React 19. In React 18, the cleanup function will be ignored and the ref will get called with + // Callback refs only work in React 19+. In React 18, the ref will get called with // `null` which will be passed to each ref as expected. + if (majorReactVersion <= 18) return + return () => { // For object refs and callback refs that don't return cleanups, we still need to pass `null` on cleanup if (cleanupA) cleanupA() From c0612a7a2fdb34ec4fbb3fd3dfd86d18696bfc64 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Mar 2026 19:48:30 +0000 Subject: [PATCH 18/18] Update tests --- packages/react/src/hooks/__tests__/useMergedRefs.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx index 43ef7f8d706..87fec1f36ce 100644 --- a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx +++ b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx @@ -1,5 +1,5 @@ import {render, renderHook} from '@testing-library/react' -import React, {forwardRef, type RefObject} from 'react' +import React, {forwardRef, type RefObject, version} from 'react' import {describe, expect, it, vi} from 'vitest' import {useMergedRefs} from '../useMergedRefs' @@ -114,7 +114,7 @@ describe('useMergedRefs', () => { expect(refB).toHaveBeenCalledExactlyOnceWith('test') }) - it('handles React 18 null values correctly', () => { + it.runIf(version.startsWith('18'))('handles React 18 null values correctly', () => { const refA = vi.fn() const refB = vi.fn() @@ -131,7 +131,7 @@ describe('useMergedRefs', () => { expect(refB).toHaveBeenCalledWith(null) }) - it('handles React 19 cleanup functions correctly and independently', () => { + it.skipIf(version.startsWith('18'))('handles React 19 cleanup functions correctly and independently', () => { const refA = vi.fn() const cleanupRefB = vi.fn() const refB = vi.fn().mockReturnValue(cleanupRefB) @@ -143,7 +143,7 @@ describe('useMergedRefs', () => { expect(refB).toHaveBeenCalledWith('test') // React 19 will call cleanup function and not pass null - cleanup() + cleanup!() expect(refA).toHaveBeenCalledWith(null) expect(refB).not.toHaveBeenCalledWith(null)