2222 * SOFTWARE.
2323 */
2424
25- import { Component , createRef , RefObject } from 'react'
25+ import { Component , forwardRef , memo , useCallback } from 'react'
2626import {
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+
62127describe ( '<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