Skip to content

Create new useCombinedRefs hook to deprecate and replace useRefObjectAsForwardedRef#7638

Open
iansan5653 wants to merge 10 commits intomainfrom
create-use-combined-refs
Open

Create new useCombinedRefs hook to deprecate and replace useRefObjectAsForwardedRef#7638
iansan5653 wants to merge 10 commits intomainfrom
create-use-combined-refs

Conversation

@iansan5653
Copy link
Contributor

Creates a new useCombinedRefs hook which solves the same problems as useRefObjectAsForwardedRef but in a safer and more convenient way. It has several advantages:

  1. Fixes the bug identified in Revert "perf(useRefObjectAsForwardedRef): add dependency array to useImperativeHandle" #7635 (comment) where callback refs would be called on every render, by using a callback ref itself as the trigger for updating the refs. This prevents possible infinite render loops where a callback ref is used to set state.
  2. Using a callback ref, only updates the refs when the target changes, gaining the perf improvement from perf(useRefObjectAsForwardedRef): add dependency array to useImperativeHandle #7553 without the bug that came with it.
  3. Doesn't care about the order of passed arguments; the current hook will silently fail to work if you switch the arguments.
  4. Can work with two callback refs, allowing for components to use callback refs internally while still accepting external refs.
  5. Is React 19 forward-compatible with callback ref cleanup functions while remaining backwards-compatible with React 18.
  6. Has better type definitions to reduce the need for casting.
  7. Accepts undefined, a significant DX improvement for using optional React 19 ref props without having to default to null.

However, the new hook is a breaking change from the old one because it returns a combined ref that must be passed to the underlying child component. So I've left the old hook in place but deprecated it with migration instructions. We can delete the old hook entirely in the next major release (see https://github.com/github/primer/issues/5430#issuecomment-4013625713).

Note: Many standard implementations of this pattern accept any number of refs as arguments. I don't think this is necessary and it adds a significant amount of complexity, so I've kept things simple by only accepting two. In the rare case that more are needed, the hook can just be called with its own result.

Changelog

New

  • Added new useCombinedRefs hook

Changed

  • Deprecated useRefObjectAsForwardedRef; see doc comment for instructions on migrating to useCombinedRefs

Removed

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

Merge checklist

@changeset-bot
Copy link

changeset-bot bot commented Mar 6, 2026

⚠️ No Changeset found

Latest commit: c53642d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Mar 6, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@iansan5653 iansan5653 marked this pull request as ready for review March 6, 2026 22:06
@iansan5653 iansan5653 requested a review from a team as a code owner March 6, 2026 22:07
@iansan5653 iansan5653 requested review from TylerJDev and Copilot March 6, 2026 22:07
@github-actions github-actions bot requested a deployment to storybook-preview-7638 March 6, 2026 22:10 Abandoned
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces a new useCombinedRefs hook in @primer/react to safely combine internal and external/forwarded refs (including React 19-style callback ref cleanup), and deprecates useRefObjectAsForwardedRef with migration guidance.

Changes:

  • Added useCombinedRefs hook (+ hook docs + unit tests) and exported it from public entrypoints.
  • Deprecated useRefObjectAsForwardedRef with inline migration instructions.
  • Migrated multiple components from useRefObjectAsForwardedRef to useCombinedRefs.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/react/src/index.ts Exports useCombinedRefs from the package root.
packages/react/src/hooks/index.ts Exports useCombinedRefs from the hooks barrel.
packages/react/src/hooks/useCombinedRefs.ts Implements new combined-ref hook with React 18/19 considerations.
packages/react/src/hooks/useCombinedRefs.hookDocs.json Adds docs metadata for the new hook.
packages/react/src/hooks/tests/useCombinedRefs.test.tsx Adds unit tests validating combined ref behavior.
packages/react/src/hooks/useRefObjectAsForwardedRef.ts Deprecates old hook and documents migration.
packages/react/src/tests/snapshots/exports.test.ts.snap Updates export snapshot to include useCombinedRefs.
packages/react/src/Button/ButtonBase.tsx Switches ButtonBase from old ref hook to useCombinedRefs.
packages/react/src/Heading/Heading.tsx Switches Heading from old ref hook to useCombinedRefs.
packages/react/src/Link/Link.tsx Switches Link from old ref hook to useCombinedRefs and removes ref casting.
packages/react/src/Dialog/Dialog.tsx Switches Dialog from old ref hook to useCombinedRefs.
packages/react/src/deprecated/DialogV1/Dialog.tsx Switches deprecated DialogV1 from old ref hook to useCombinedRefs.
packages/react/src/Overlay/Overlay.tsx Switches Overlay from old ref hook to useCombinedRefs.
packages/react/src/PageLayout/PageLayout.tsx Switches PageLayout Pane/Sidebar forwarded refs to useCombinedRefs.
packages/react/src/TextInputWithTokens/TextInputWithTokens.tsx Switches TextInputWithTokens ref forwarding to useCombinedRefs.
packages/react/src/Autocomplete/AutocompleteInput.tsx Switches AutocompleteInput ref forwarding to useCombinedRefs.
packages/react/src/Autocomplete/AutocompleteOverlay.tsx Switches AutocompleteOverlay floating/scroll refs to useCombinedRefs.

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

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +83
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback in
// our own combined ref.
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Comment typo: "callback react" should be "callback ref".

Suggested change
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback in
// our own combined ref.
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback ref
// in our own combined ref.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +93
function setRef<T>(ref: Ref<T>, value: T, cleanupRef: MutableRefObject<CleanupFunction | undefined>) {
// NOTE: This is technically incorrect; in React 19 even with a callback ref it should be possible to call a ref with
// `null` if it happens before unmount. But there's no way for a React 18 ref to know whether this update is happening
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback in
// our own combined ref.
if (value === null && cleanupRef.current) return

if (typeof ref === 'function') {
const cleanup = ref(value)
cleanupRef.current = cleanup || undefined
} else if (ref) {
// `React.Ref` is typed as immutable to protect consumers but it's OK to mutate it here. We could just change the
// type to only accept mutable refs, but then it would be harder to accept refs as props in React 19 because we
// would have to use the `React.ForwardedRef` type instead of `React.Ref`
;(ref as MutableRefObject<T>).current = value
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

useCombinedRefs calls setRef(refA, value, ...) with value typed as T | null, but setRef currently accepts value: T. This is a type mismatch and also forces unsafe casts when writing ref.current. Update setRef to accept T | null (and adjust the object-ref cast to MutableRefObject<T | null>).

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +85
// NOTE: This is technically incorrect; in React 19 even with a callback ref it should be possible to call a ref with
// `null` if it happens before unmount. But there's no way for a React 18 ref to know whether this update is happening
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback in
// our own combined ref.
if (value === null && cleanupRef.current) return

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Cleanup functions returned by callback refs are never invoked when the target ref changes: on value === null the function returns early, and on a subsequent non-null value the old cleanup is overwritten. This can leak subscriptions/event listeners and diverges from React 19 semantics (cleanup should run on unmount and when the ref target changes). Consider calling any existing cleanup before setting a new non-null value and when receiving null, then clearing cleanupRef.current.

Suggested change
// NOTE: This is technically incorrect; in React 19 even with a callback ref it should be possible to call a ref with
// `null` if it happens before unmount. But there's no way for a React 18 ref to know whether this update is happening
// during unmount or not. Once we only need to support React 19, this can be corrected using a cleanup callback in
// our own combined ref.
if (value === null && cleanupRef.current) return
// If we already have a cleanup, call it before updating the ref. When `value` is `null` and a cleanup exists, mimic
// React 19 semantics by calling the cleanup instead of calling the ref with `null`.
if (value === null && cleanupRef.current) {
cleanupRef.current()
cleanupRef.current = undefined
return
}
if (cleanupRef.current) {
cleanupRef.current()
cleanupRef.current = undefined
}

Copilot uses AI. Check for mistakes.
Comment on lines 15 to 18
const Heading = forwardRef(({as: Component = 'h2', className, variant, ...props}, forwardedRef) => {
const innerRef = React.useRef<HTMLHeadingElement>(null)
useRefObjectAsForwardedRef(forwardedRef, innerRef)
useCombinedRefs(forwardedRef, innerRef)

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.

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

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.

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

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.
Comment on lines 49 to 52
const overlayRef = useRef(null)
const modalRef = useRef<HTMLDivElement>(null)
useRefObjectAsForwardedRef(forwardedRef, modalRef)
useCombinedRefs(forwardedRef, modalRef)
const closeButtonRef = useRef(null)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
@github-actions github-actions bot requested a deployment to storybook-preview-7638 March 6, 2026 22:16 Abandoned
@primer
Copy link
Contributor

primer bot commented Mar 6, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

@github-actions github-actions bot requested a deployment to storybook-preview-7638 March 6, 2026 22:22 Abandoned
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants