Skip to content

Commit d9171f0

Browse files
committed
fix(ui-motion,ui-alerts): silence "ref is not a prop" warning when wrapping class components in Transition
BaseTransition.renderChildren inspects the child's React type to decide which ref props to attach: - host elements (<div>, <span>) receive a plain ref callback - forwardRef components (including memo-wrapped ones) receive both elementRef (the InstUI convention) and ref (for non-InstUI forwardRef children) - any other component shape (class, plain function, Context.Provider, etc.) receives elementRef only, since attaching ref to a non-forwardRef component would bind to the class instance or trigger a React warning handleChildRef ignores non-Element values so that a forwardRef chain landing on a class instance does not clobber a DOM node already captured via elementRef. Alert.handleRef forwards the consumer-supplied elementRef so that parents like Transition can observe Alert's mounted DOM node; without chaining, Alert silently swallowed the elementRef it received.
1 parent 84f11c1 commit d9171f0

4 files changed

Lines changed: 255 additions & 22 deletions

File tree

packages/ui-alerts/src/Alert/v1/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ class Alert extends Component<AlertProps, AlertState> {
9898

9999
handleRef = (el: Element | null) => {
100100
this.ref = el
101+
const { elementRef } = this.props as AlertProps & {
102+
elementRef?: (el: Element | null) => void
103+
}
104+
if (typeof elementRef === 'function') {
105+
elementRef(el)
106+
}
101107
}
102108

103109
handleTimeout = () => {

packages/ui-alerts/src/Alert/v2/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ class Alert extends Component<AlertProps, AlertState> {
9797

9898
handleRef = (el: Element | null) => {
9999
this.ref = el
100+
const { elementRef } = this.props as AlertProps & {
101+
elementRef?: (el: Element | null) => void
102+
}
103+
if (typeof elementRef === 'function') {
104+
elementRef(el)
105+
}
100106
}
101107

102108
handleTimeout = () => {

packages/ui-motion/src/Transition/BaseTransition/index.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@
2424

2525
import { Component, ReactElement } from 'react'
2626

27-
import { getClassList, findDOMNode } from '@instructure/ui-dom-utils'
27+
import { getClassList } from '@instructure/ui-dom-utils'
2828
import {
2929
ensureSingleChild,
3030
safeCloneElement
3131
} from '@instructure/ui-react-utils'
32+
import { createChainedFunction } from '@instructure/ui-utils'
3233

3334
import { allowedProps } from './props'
3435
import type {
@@ -38,6 +39,24 @@ import type {
3839
BaseTransitionStatesType
3940
} from './props'
4041

42+
const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref')
43+
const REACT_MEMO_TYPE = Symbol.for('react.memo')
44+
45+
type ElementRefCallback = (el: Element | null) => void
46+
type ChildElementType = ReactElement['type']
47+
48+
function unwrapMemo(type: ChildElementType): ChildElementType {
49+
let unwrapped = type
50+
while (
51+
unwrapped &&
52+
typeof unwrapped !== 'string' &&
53+
(unwrapped as { $$typeof?: symbol }).$$typeof === REACT_MEMO_TYPE
54+
) {
55+
unwrapped = (unwrapped as unknown as { type: ChildElementType }).type
56+
}
57+
return unwrapped
58+
}
59+
4160
const STATES: Record<BaseTransitionStateValue, BaseTransitionStatesType> = {
4261
EXITED: -2,
4362
EXITING: -1,
@@ -88,6 +107,16 @@ class BaseTransition extends Component<
88107
}
89108
}
90109

110+
handleChildRef = (el: unknown) => {
111+
// The `ref` we attach to a forwardRef child can end up bound to a class
112+
// instance (when the forwardRef chain wraps a class component). Ignore
113+
// those so they don't clobber a DOM node we got via `elementRef`.
114+
if (el != null && !(el instanceof Element)) return
115+
const next = el instanceof Element ? el : null
116+
if (next === this.ref) return
117+
this.handleRef(next)
118+
}
119+
91120
componentDidMount() {
92121
this.startTransition(this.props.in, this.props.transitionOnMount)
93122
this._unmounted = false
@@ -333,19 +362,45 @@ class BaseTransition extends Component<
333362
}
334363

335364
renderChildren() {
336-
return this.props.children
337-
? safeCloneElement(
338-
ensureSingleChild(this.props.children) as ReactElement,
339-
{
340-
'aria-hidden': !this.props.in ? true : undefined,
341-
ref: (el: React.ReactInstance | null) => {
342-
const ref = (findDOMNode(el) as Element) || null
343-
344-
this.handleRef(ref)
345-
}
346-
}
347-
)
348-
: null
365+
if (!this.props.children) return null
366+
367+
const child = ensureSingleChild(this.props.children) as ReactElement
368+
369+
return safeCloneElement(child, {
370+
'aria-hidden': !this.props.in ? true : undefined,
371+
...(this.refPropsForChild(child) as Record<string, unknown>)
372+
})
373+
}
374+
375+
// Host elements receive a plain `ref`. `forwardRef` components (including
376+
// memo-wrapped ones) receive both `ref` (for non-InstUI forwardRef
377+
// children) and `elementRef` (the InstUI convention). Any other component
378+
// shape (class, function, Context.Provider, etc.) only gets `elementRef` —
379+
// attaching `ref` to a non-forwardRef component would either bind to the
380+
// class instance (which we can't resolve to a DOM node without
381+
// findDOMNode) or trigger a React "Function components cannot be given
382+
// refs" warning.
383+
// If the child already carries an `elementRef` prop from the consumer,
384+
// we chain it with ours so the consumer's callback isn't clobbered.
385+
refPropsForChild(child: ReactElement) {
386+
if (typeof child.type === 'string') {
387+
return { ref: this.handleChildRef }
388+
}
389+
390+
const consumerElementRef = child.props?.elementRef
391+
const mergedElementRef: ElementRefCallback =
392+
typeof consumerElementRef === 'function'
393+
? (createChainedFunction(
394+
consumerElementRef as ElementRefCallback,
395+
this.handleChildRef
396+
) as ElementRefCallback)
397+
: this.handleChildRef
398+
399+
const unwrapped = unwrapMemo(child.type) as { $$typeof?: symbol }
400+
if (unwrapped?.$$typeof === REACT_FORWARD_REF_TYPE) {
401+
return { elementRef: mergedElementRef, ref: this.handleChildRef }
402+
}
403+
return { elementRef: mergedElementRef }
349404
}
350405

351406
render() {

packages/ui-motion/src/Transition/__tests__/Transition.test.tsx

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
* SOFTWARE.
2323
*/
2424

25-
import { Component, createRef, RefObject } from 'react'
25+
import { Component, forwardRef, memo, useCallback } from 'react'
2626
import {
2727
render,
2828
waitFor,
@@ -47,18 +47,83 @@ const getClass = (
4747
return classNames[phase]
4848
}
4949

50-
class ExampleComponent extends Component<any, any> {
51-
private ref: RefObject<any>
50+
// A non-InstUI forwardRef component: exposes the DOM node via React's
51+
// built-in ref forwarding only. Intentionally does not spread all props
52+
// onto the DOM so unrecognized props (like the InstUI-convention
53+
// `elementRef` BaseTransition also passes) don't leak as DOM attributes.
54+
const ExampleComponent = forwardRef<HTMLDivElement>((_props, ref) => {
55+
return <div ref={ref}>{COMPONENT_TEXT}</div>
56+
})
57+
ExampleComponent.displayName = 'ExampleComponent'
5258

53-
constructor(props: any) {
54-
super(props)
55-
this.ref = createRef()
56-
}
59+
// Mirrors the InstUI functional-component pattern: exposes the DOM node via
60+
// an `elementRef` prop rather than via React's built-in ref forwarding.
61+
type InstUIStyleComponentProps = {
62+
elementRef?: (el: Element | null) => void
63+
}
64+
const InstUIStyleComponent = ({ elementRef }: InstUIStyleComponentProps) => {
65+
return (
66+
<div
67+
ref={(el) => {
68+
if (elementRef) elementRef(el)
69+
}}
70+
>
71+
{COMPONENT_TEXT}
72+
</div>
73+
)
74+
}
75+
76+
// Mirrors an InstUI v1 class component: exposes the DOM node only via
77+
// `elementRef`, not via React's built-in ref.
78+
class InstUIStyleClassComponent extends Component<InstUIStyleComponentProps> {
5779
render() {
58-
return <div ref={this.ref}>{COMPONENT_TEXT}</div>
80+
const { elementRef } = this.props
81+
return (
82+
<div
83+
ref={(el) => {
84+
if (elementRef) elementRef(el)
85+
}}
86+
>
87+
{COMPONENT_TEXT}
88+
</div>
89+
)
5990
}
6091
}
6192

93+
// Honors both `ref` (via forwardRef) and `elementRef` (InstUI convention),
94+
// letting us verify BaseTransition doesn't fire its ref bookkeeping twice
95+
// when a child wires up both.
96+
const DualRefComponent = forwardRef<HTMLDivElement, InstUIStyleComponentProps>(
97+
({ elementRef }, ref) => {
98+
// Memoized to avoid a fresh ref callback on every render, which would
99+
// cause React to detach/reattach the ref and muddy the dedupe assertion.
100+
const setRef = useCallback(
101+
(el: HTMLDivElement | null) => {
102+
if (typeof ref === 'function') {
103+
ref(el)
104+
} else if (ref) {
105+
// eslint-disable-next-line no-param-reassign
106+
ref.current = el
107+
}
108+
if (elementRef) elementRef(el)
109+
},
110+
[ref, elementRef]
111+
)
112+
return <div ref={setRef}>{COMPONENT_TEXT}</div>
113+
}
114+
)
115+
DualRefComponent.displayName = 'DualRefComponent'
116+
117+
// memo(forwardRef(...)) — a common third-party shape. BaseTransition must
118+
// unwrap the memo layer to detect the forwardRef underneath, otherwise the
119+
// DOM node isn't captured.
120+
const MemoForwardRefComponent = memo(
121+
forwardRef<HTMLDivElement>((_props, ref) => {
122+
return <div ref={ref}>{COMPONENT_TEXT}</div>
123+
})
124+
)
125+
MemoForwardRefComponent.displayName = 'MemoForwardRefComponent'
126+
62127
describe('<Transition />', () => {
63128
let consoleWarningMock: ReturnType<typeof vi.spyOn>
64129
let consoleErrorMock: ReturnType<typeof vi.spyOn>
@@ -107,6 +172,33 @@ describe('<Transition />', () => {
107172
)
108173
const element = getByText(COMPONENT_TEXT)
109174

175+
expect(element).toHaveClass(getClass(type, 'entered'))
176+
// BaseTransition passes both `ref` and `elementRef` to component
177+
// children so InstUI and non-InstUI forwardRef components both work.
178+
// A well-behaved forwardRef child must not leak the unrecognized
179+
// `elementRef` prop onto the DOM (React would warn in dev).
180+
expect(consoleErrorMock).not.toHaveBeenCalled()
181+
})
182+
183+
it(`should correctly apply classes for '${type}' with a component that only exposes elementRef`, () => {
184+
const { getByText } = render(
185+
<Transition type={type} in={true}>
186+
<InstUIStyleComponent />
187+
</Transition>
188+
)
189+
const element = getByText(COMPONENT_TEXT)
190+
191+
expect(element).toHaveClass(getClass(type, 'entered'))
192+
})
193+
194+
it(`should correctly apply classes for '${type}' with a class component that only exposes elementRef`, () => {
195+
const { getByText } = render(
196+
<Transition type={type} in={true}>
197+
<InstUIStyleClassComponent />
198+
</Transition>
199+
)
200+
const element = getByText(COMPONENT_TEXT)
201+
110202
expect(element).toHaveClass(getClass(type, 'entered'))
111203
})
112204
}
@@ -275,4 +367,78 @@ describe('<Transition />', () => {
275367
expect(onExited).toHaveBeenCalled()
276368
})
277369
})
370+
371+
it("should forward the child's rendered DOM element to Transition's elementRef", () => {
372+
const transitionElementRef = vi.fn()
373+
374+
const { getByText } = render(
375+
<Transition type="fade" in={true} elementRef={transitionElementRef}>
376+
<InstUIStyleComponent />
377+
</Transition>
378+
)
379+
const element = getByText(COMPONENT_TEXT)
380+
381+
expect(transitionElementRef).toHaveBeenCalledWith(element)
382+
})
383+
384+
it('should not double-fire elementRef when the child honors both ref and elementRef', () => {
385+
const transitionElementRef = vi.fn()
386+
387+
render(
388+
<Transition type="fade" in={true} elementRef={transitionElementRef}>
389+
<DualRefComponent />
390+
</Transition>
391+
)
392+
393+
const calls = transitionElementRef.mock.calls.filter(
394+
([arg]) => arg instanceof Element
395+
)
396+
expect(calls).toHaveLength(1)
397+
})
398+
399+
it('should capture the DOM of a memo(forwardRef(...)) child', () => {
400+
const transitionElementRef = vi.fn()
401+
402+
const { getByText } = render(
403+
<Transition type="fade" in={true} elementRef={transitionElementRef}>
404+
<MemoForwardRefComponent />
405+
</Transition>
406+
)
407+
const element = getByText(COMPONENT_TEXT)
408+
409+
expect(element).toHaveClass(getClass('fade', 'entered'))
410+
expect(transitionElementRef).toHaveBeenCalledWith(element)
411+
})
412+
413+
it("should chain the consumer's own elementRef with Transition's", () => {
414+
const transitionElementRef = vi.fn()
415+
const childElementRef = vi.fn()
416+
417+
const { getByText } = render(
418+
<Transition type="fade" in={true} elementRef={transitionElementRef}>
419+
<InstUIStyleComponent elementRef={childElementRef} />
420+
</Transition>
421+
)
422+
const element = getByText(COMPONENT_TEXT)
423+
424+
expect(transitionElementRef).toHaveBeenCalledWith(element)
425+
expect(childElementRef).toHaveBeenCalledWith(element)
426+
})
427+
428+
it('should not throw when the child has a non-callback elementRef prop', () => {
429+
// Some consumers may pass a RefObject instead of a callback. InstUI's
430+
// convention is callback-style, but a runtime throw would be worse than
431+
// quietly ignoring the unsupported shape.
432+
const objectRef = { current: null }
433+
434+
expect(() =>
435+
render(
436+
<Transition type="fade" in={true}>
437+
<InstUIStyleComponent
438+
elementRef={objectRef as unknown as (el: Element | null) => void}
439+
/>
440+
</Transition>
441+
)
442+
).not.toThrow()
443+
})
278444
})

0 commit comments

Comments
 (0)