Skip to content

Commit b2ed501

Browse files
authored
update useProvidedRefOrCreate hook to support function refs (#1081)
1 parent 0d76606 commit b2ed501

File tree

3 files changed

+154
-5
lines changed

3 files changed

+154
-5
lines changed

.changeset/sixty-walls-pull.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@primer/react-brand': patch
3+
---
4+
5+
Updated `useProvidedRefOrCreate` to support functional refs. Backwards compatibility with `RefObject` remains unchanged.
6+
7+
This update improves support for functional refs in the following components: `Accordion`, `Checkbox`, `Radio`, `SubNav`, `Tooltip` and `VideoPlayer`.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React from 'react'
2+
import {render, cleanup} from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import '@testing-library/jest-dom'
5+
import {toHaveNoViolations} from 'jest-axe'
6+
7+
import {Checkbox} from '../Checkbox'
8+
9+
expect.extend(toHaveNoViolations)
10+
11+
describe('Checkbox', () => {
12+
afterEach(cleanup)
13+
14+
it('renders a checkbox correctly into the document', () => {
15+
const {getByRole} = render(<Checkbox value="one" defaultChecked />)
16+
17+
const checkbox = getByRole('checkbox')
18+
expect(checkbox).toBeInTheDocument()
19+
expect(checkbox).toBeChecked()
20+
expect(checkbox).toHaveAttribute('value', 'one')
21+
})
22+
23+
it('toggles the checkbox when clicked', async () => {
24+
const user = userEvent.setup()
25+
26+
const {getByRole} = render(<Checkbox value="one" defaultChecked />)
27+
28+
const checkbox = getByRole('checkbox')
29+
30+
expect(checkbox).toBeChecked()
31+
32+
await user.click(checkbox)
33+
expect(checkbox).not.toBeChecked()
34+
35+
await user.click(checkbox)
36+
expect(checkbox).toBeChecked()
37+
})
38+
39+
it('toggles the checkbox when the associated span is clicked', async () => {
40+
const user = userEvent.setup()
41+
42+
const {getByRole, container} = render(<Checkbox value="one" defaultChecked />)
43+
44+
const checkbox = getByRole('checkbox')
45+
const span = container.querySelector('span.Checkbox') as HTMLSpanElement
46+
47+
expect(span).toBeInTheDocument()
48+
expect(checkbox).toBeChecked()
49+
50+
await user.click(span)
51+
expect(checkbox).not.toBeChecked()
52+
53+
await user.click(span)
54+
expect(checkbox).toBeChecked()
55+
})
56+
57+
it('toggles the checkbox when the associated span is clicked and a functional ref is passed', async () => {
58+
const user = userEvent.setup()
59+
const mockRef = jest.fn()
60+
61+
const {getByRole, container} = render(<Checkbox value="one" defaultChecked ref={mockRef} />)
62+
63+
const checkbox = getByRole('checkbox')
64+
const span = container.querySelector('span.Checkbox') as HTMLSpanElement
65+
66+
expect(mockRef).toHaveBeenCalledWith(checkbox)
67+
expect(checkbox).toBeChecked()
68+
69+
await user.click(span)
70+
expect(checkbox).not.toBeChecked()
71+
72+
await user.click(span)
73+
expect(checkbox).toBeChecked()
74+
})
75+
76+
it('remains clickable when passing an arbitrary function as a ref', async () => {
77+
// eslint-disable-next-line @typescript-eslint/no-empty-function
78+
const {getByRole} = render(<Checkbox value="test" ref={() => {}} />)
79+
80+
const checkbox = getByRole('checkbox')
81+
expect(checkbox).not.toBeChecked()
82+
83+
const user = userEvent.setup()
84+
await user.click(checkbox)
85+
expect(checkbox).toBeChecked()
86+
})
87+
88+
it('supports a standard RefObject', async () => {
89+
const user = userEvent.setup()
90+
const refObject = React.createRef<HTMLInputElement>()
91+
92+
const {getByRole} = render(<Checkbox value="test" ref={refObject} />)
93+
94+
const checkbox = getByRole('checkbox')
95+
96+
expect(refObject.current).toBe(checkbox)
97+
expect(checkbox).not.toBeChecked()
98+
99+
if (refObject.current) {
100+
await user.click(refObject.current)
101+
}
102+
103+
expect(checkbox).toBeChecked()
104+
expect(refObject.current?.checked).toBe(true)
105+
})
106+
})

packages/react/src/hooks/useRef.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,52 @@
1-
import React, {useRef} from 'react'
1+
import React, {useRef, useMemo} from 'react'
22

33
/**
44
* There are some situations where we only want to create a new ref if one is not provided to a component
55
* or hook as a prop. However, due to the `rules-of-hooks`, we cannot conditionally make a call to `React.useRef`
66
* only in the situations where the ref is not provided as a prop.
77
* This hook aims to encapsulate that logic, so the consumer doesn't need to be concerned with violating `rules-of-hooks`.
8-
* @param providedRef The ref to use - if undefined, will use the ref from a call to React.useRef
8+
* @param providedRef The ref to use - can be a RefObject, RefCallback (functional ref), null or undefined.
99
* @type TRef The type of the RefObject which should be created.
10+
* @returns A RefObject that either uses the provided ref, wraps a functional ref, or creates a new one
1011
*/
11-
12-
export function useProvidedRefOrCreate<TRef>(providedRef?: React.RefObject<TRef>): React.RefObject<TRef> {
12+
export function useProvidedRefOrCreate<TRef>(
13+
providedRef?: React.RefObject<TRef> | React.RefCallback<TRef> | null,
14+
): React.RefObject<TRef> {
1315
const createdRef = useRef<TRef>(null)
16+
const wrapperRef = useRef<React.MutableRefObject<TRef | null> | null>(null)
17+
18+
const finalRef = useMemo(() => {
19+
// Handle function refs
20+
// We wrap it in a MutableRefObject to ensure we can set the current value
21+
// and also call the callback with the latest value
22+
if (typeof providedRef === 'function') {
23+
if (!wrapperRef.current) {
24+
let currentVal: TRef | null = null
25+
26+
wrapperRef.current = {
27+
get current() {
28+
return currentVal
29+
},
30+
set current(value: TRef | null) {
31+
currentVal = value
32+
33+
providedRef(value)
34+
},
35+
}
36+
}
37+
return wrapperRef.current
38+
}
39+
40+
wrapperRef.current = null
41+
42+
// Return the provided ref if it's a RefObject
43+
if (providedRef && typeof providedRef === 'object' && providedRef.current !== undefined) {
44+
return providedRef
45+
}
46+
47+
// Return a new ref if it's neither a function nor a RefObject
48+
return createdRef
49+
}, [providedRef, createdRef])
1450

15-
return providedRef ?? createdRef
51+
return finalRef
1652
}

0 commit comments

Comments
 (0)