diff --git a/packages/ui-motion/src/Transition/BaseTransition/index.ts b/packages/ui-motion/src/Transition/BaseTransition/index.ts index 01efb056a4..c06b304c57 100644 --- a/packages/ui-motion/src/Transition/BaseTransition/index.ts +++ b/packages/ui-motion/src/Transition/BaseTransition/index.ts @@ -347,26 +347,39 @@ class BaseTransition extends Component< } } - // `typeof type === 'object'` => forwardRef wrapper (withStyle-decorated InstUI components) - const refProps = - typeof child.type === 'object' - ? { - // chain so the child's own elementRef still fires instead of being overwritten - elementRef: createChainedFunction( - (child.props as { elementRef?: (el: Element | null) => void }) - ?.elementRef, - this.handleRef - ), - // fallback for forwardRef children that expose their node via `ref`, not elementRef - ref: elementOnlyRef - } - : { - // for host el / plain class|fn: findDOMNode is the fallback - ref: (el: ReactInstance | Element | null) => - this.handleRef( - el instanceof Element ? el : (findDOMNode(el) as Element) ?? null - ) - } + // `typeof type === 'object'` covers every forwardRef wrapper, not just + // withStyle-decorated InstUI components. `@emotion/react`'s wrapper matches + // too, and it forwards unknown props straight to the DOM node, so passing + // `elementRef` to it makes React warn about an unrecognized prop. Require + // the child to declare `elementRef` before handing it one; anything else + // gets a plain `ref`. + const acceptsElementRef = + typeof child.type === 'object' && + Array.isArray( + (child.type as { allowedProps?: readonly string[] }).allowedProps + ) && + (child.type as { allowedProps: readonly string[] }).allowedProps.includes( + 'elementRef' + ) + + const refProps = acceptsElementRef + ? { + // chain so the child's own elementRef still fires instead of being overwritten + elementRef: createChainedFunction( + (child.props as { elementRef?: (el: Element | null) => void }) + ?.elementRef, + this.handleRef + ), + // fallback for forwardRef children that expose their node via `ref`, not elementRef + ref: elementOnlyRef + } + : { + // for host el / plain class|fn: findDOMNode is the fallback + ref: (el: ReactInstance | Element | null) => + this.handleRef( + el instanceof Element ? el : (findDOMNode(el) as Element) ?? null + ) + } return safeCloneElement(child, { 'aria-hidden': !this.props.in ? true : undefined, diff --git a/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx b/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx index 4263cc488e..ec1a76d2c4 100644 --- a/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx +++ b/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { Component, createRef, RefObject } from 'react' +import { Component, createRef, forwardRef, RefObject } from 'react' import { render, waitFor, @@ -275,4 +275,77 @@ describe('', () => { expect(onExited).toHaveBeenCalled() }) }) + + describe('ref forwarding to the child', () => { + // mirrors `@emotion/react`'s wrapper: a forwardRef component (so + // `typeof type === 'object'`) that spreads whatever it is given onto a + // host element + const SpreadingChild = forwardRef((props, ref) => ( +
+ {COMPONENT_TEXT} +
+ )) + SpreadingChild.displayName = 'SpreadingChild' + + const elementRefWarnings = (mock: ReturnType) => + mock.mock.calls.filter((args: unknown[]) => + args.join(' ').includes('elementRef') + ) + + it('does not pass elementRef to a child that does not accept it', async () => { + const { getByText } = render( + + + + ) + const child = getByText(COMPONENT_TEXT) + + expect(child).not.toHaveAttribute('elementref') + expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0) + }) + + it('still finds the child node when elementRef is withheld', async () => { + const elementRef = vi.fn() + render( + + + + ) + + await waitFor(() => { + expect(elementRef).toHaveBeenCalledWith(expect.any(Element)) + }) + }) + + it('passes elementRef to a child that declares it in allowedProps', async () => { + const childElementRef = vi.fn() + const AcceptingChild = forwardRef( + ({ elementRef, ...rest }, ref) => ( +
{ + elementRef?.(el) + if (typeof ref === 'function') ref(el) + }} + {...rest} + > + {COMPONENT_TEXT} +
+ ) + ) as any + AcceptingChild.displayName = 'AcceptingChild' + AcceptingChild.allowedProps = ['elementRef'] + + const { getByText } = render( + + + + ) + + // the child's own elementRef is chained, not overwritten + await waitFor(() => { + expect(childElementRef).toHaveBeenCalledWith(expect.any(Element)) + }) + expect(getByText(COMPONENT_TEXT)).not.toHaveAttribute('elementref') + }) + }) })