Skip to content

Commit d095239

Browse files
authored
fix: forward child refs in Tooltip and TabList on React 18 (#4904)
* fix: forward child refs in Tooltip and TabList on React 18 React 18 stores refs on element.ref while React 19 stores them on element.props.ref. Both Tooltip and TabList only read props.ref, so on React 18 the user's ref was silently dropped during cloneElement. Adds a getElementRef helper in eds-utils that reads from both locations (props.ref first to avoid the React 19 deprecation warning) and uses it in both call sites. Closes #4898 * refactor: tighten getElementRef return type to Ref<T> | null React's Ref<T> doesn't include undefined, so returning Ref<T> | null matches the type expected by mergeRefs and lets TabList drop its ?? null coercion at the call site.
1 parent 3b95dac commit d095239

5 files changed

Lines changed: 64 additions & 8 deletions

File tree

packages/eds-core-react/src/components/Tabs/TabList.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
isValidElement,
1212
} from 'react'
1313
import styled from 'styled-components'
14-
import { mergeRefs } from '@equinor/eds-utils'
14+
import { mergeRefs, getElementRef } from '@equinor/eds-utils'
1515
import { TabsContext } from './Tabs.context'
1616
import { Variants } from './Tabs.types'
1717

@@ -96,7 +96,6 @@ const TabList = forwardRef<HTMLDivElement, TabListProps>(function TabsList(
9696
type ChildPropsWithRef = {
9797
value?: string | number
9898
disabled?: boolean
99-
ref?: React.Ref<HTMLButtonElement>
10099
[key: string]: unknown
101100
}
102101

@@ -106,7 +105,7 @@ const TabList = forwardRef<HTMLDivElement, TabListProps>(function TabsList(
106105
? controlledActive === activeTab
107106
: $index === activeTab
108107

109-
const childRef = childProps?.ref || null
108+
const childRef = getElementRef<HTMLButtonElement>(child)
110109
const tabRef =
111110
isActive && childRef
112111
? mergeRefs<HTMLButtonElement>(childRef, selectedTabRef)

packages/eds-core-react/src/components/Tooltip/Tooltip.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
typographyTemplate,
1818
bordersTemplate,
1919
mergeRefs,
20+
getElementRef,
2021
} from '@equinor/eds-utils'
2122
import { tooltip as tokens } from './Tooltip.tokens'
2223
import {
@@ -193,11 +194,7 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
193194
children as ReactElement<any>, // eslint-disable-line @typescript-eslint/no-explicit-any
194195
{
195196
...getReferenceProps(children.props),
196-
ref: mergeRefs(
197-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
198-
(children as ReactElement<any>).props.ref,
199-
mergedAnchorRef,
200-
),
197+
ref: mergeRefs(getElementRef<HTMLElement>(children), mergedAnchorRef),
201198
},
202199
)
203200

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { createRef, type ReactElement } from 'react'
3+
import { getElementRef } from './getElementRef'
4+
5+
describe('getElementRef', () => {
6+
it('reads ref from props (React 19 shape)', () => {
7+
const ref = createRef<HTMLDivElement>()
8+
const element = {
9+
type: 'div',
10+
props: { ref },
11+
ref: null,
12+
} as unknown as ReactElement
13+
14+
expect(getElementRef(element)).toBe(ref)
15+
})
16+
17+
it('falls back to element.ref (React 18 shape)', () => {
18+
const ref = createRef<HTMLDivElement>()
19+
const element = {
20+
type: 'div',
21+
props: {},
22+
ref,
23+
} as unknown as ReactElement
24+
25+
expect(getElementRef(element)).toBe(ref)
26+
})
27+
28+
it('returns null when no ref is set', () => {
29+
const element = {
30+
type: 'div',
31+
props: {},
32+
} as unknown as ReactElement
33+
34+
expect(getElementRef(element)).toBeNull()
35+
})
36+
37+
it('prefers props.ref over element.ref when both are present', () => {
38+
const propsRef = createRef<HTMLDivElement>()
39+
const elementRef = createRef<HTMLDivElement>()
40+
const element = {
41+
type: 'div',
42+
props: { ref: propsRef },
43+
ref: elementRef,
44+
} as unknown as ReactElement
45+
46+
expect(getElementRef(element)).toBe(propsRef)
47+
})
48+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ReactElement, Ref } from 'react'
2+
3+
// React 18 stores the ref on `element.ref`; React 19 stores it on `element.props.ref`.
4+
// Read props.ref first to avoid the React 19 deprecation warning on element.ref access.
5+
export const getElementRef = <T = unknown>(
6+
element: ReactElement,
7+
): Ref<T> | null => {
8+
const propsRef = (element as { props?: { ref?: Ref<T> } }).props?.ref
9+
if (propsRef != null) return propsRef
10+
return (element as unknown as { ref?: Ref<T> }).ref ?? null
11+
}

packages/eds-utils/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './templates'
22
export { joinHandlers } from './joinHandlers'
33
export { mergeRefs } from './mergeRefs'
4+
export { getElementRef } from './getElementRef'
45
export { setReactInputValue } from './setReactInputValue'
56
export type { OverridableComponent } from './overridableComponent'
67
export * from './browserUtils'

0 commit comments

Comments
 (0)