diff --git a/packages/ui-motion/src/Transition/BaseTransition/index.ts b/packages/ui-motion/src/Transition/BaseTransition/index.ts
index 01efb056a4..8c7661d9fb 100644
--- a/packages/ui-motion/src/Transition/BaseTransition/index.ts
+++ b/packages/ui-motion/src/Transition/BaseTransition/index.ts
@@ -341,32 +341,35 @@ class BaseTransition extends Component<
const child = ensureSingleChild(this.props.children) as ReactElement
- const elementOnlyRef = (el: ReactInstance | Element | null) => {
- if (el instanceof Element) {
- this.handleRef(el)
- }
- }
-
- // `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
- )
+ // Only children that declare `elementRef` get one. `typeof child.type ===
+ // 'object'` also matches emotion's wrapper around `
`, which
+ // forwards it to the DOM.
+ const acceptsElementRef = (
+ 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: (el: ReactInstance | Element | null) => {
+ if (el instanceof Element) {
+ this.handleRef(el)
+ }
}
+ }
+ : {
+ // 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 67a01740b4..d37c4ca3ae 100644
--- a/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx
+++ b/packages/ui-motion/src/Transition/__tests__/Transition.test.tsx
@@ -23,11 +23,14 @@
*/
import { Component, createRef, RefObject } from 'react'
+import type { ComponentType } from 'react'
import { render } from 'vitest-browser-react'
import { page } from 'vitest/browser'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { MockInstance } from 'vitest'
+import { withStyle } from '@instructure/emotion'
+
import { Transition } from '../index.js'
import { getClassNames } from '../styles.js'
@@ -55,6 +58,22 @@ class ExampleComponent extends Component
{
}
}
+// stands in for a real InstUI component, which ui-motion can't import (they
+// depend on it)
+type StyledChildProps = { elementRef?: (el: Element | null) => void }
+
+class StyledChildBase extends Component {
+ static allowedProps = ['elementRef']
+ render() {
+ return {COMPONENT_TEXT}
+ }
+}
+
+const StyledChild = withStyle(
+ () => ({}),
+ () => ({})
+)(StyledChildBase) as unknown as ComponentType
+
describe('', () => {
let consoleWarningMock: ReturnType
let consoleErrorMock: ReturnType
@@ -271,4 +290,72 @@ describe('', () => {
expect(onExited).toHaveBeenCalled()
})
})
+
+ describe('capturing the child node', () => {
+ const warningsMatching = (mock: MockInstance, pattern: RegExp) =>
+ mock.mock.calls.filter((args: unknown[]) => pattern.test(args.join(' ')))
+
+ const elementRefWarnings = (mock: MockInstance) =>
+ warningsMatching(mock, /elementRef/)
+
+ const refIsNotAPropWarnings = (mock: MockInstance) =>
+ warningsMatching(mock, /`?ref`? is not a prop/)
+
+ it('does not leak elementRef onto an emotion-wrapped host element', async () => {
+ await render(
+
+ hello
+
+ )
+ const element = page.getByText('hello').element()
+
+ expect(element).not.toHaveAttribute('elementref')
+ expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
+ })
+
+ it('still captures an emotion-wrapped host element', async () => {
+ const elementRef = vi.fn()
+ await render(
+
+ hello
+
+ )
+
+ // the node reached handleRef, so the transition classes could be applied
+ expect(page.getByText('hello').element()).toHaveClass(
+ getClass('fade', 'entered')
+ )
+ await vi.waitFor(() => {
+ expect(elementRef).toHaveBeenCalledWith(expect.any(Element))
+ })
+ })
+
+ // a withStyle child plus a running transition made React read `ref` off the
+ // element; both conditions are needed to reproduce it
+ it('does not read `ref` off a withStyle child mid-transition', async () => {
+ const childElementRef = vi.fn()
+ const transitionElementRef = vi.fn()
+
+ await render(
+
+
+
+ )
+
+ await vi.waitFor(() => {
+ // the child's own elementRef is chained, not overwritten, and
+ // Transition still captured the node
+ expect(childElementRef).toHaveBeenCalledWith(expect.any(Element))
+ expect(transitionElementRef).toHaveBeenCalledWith(expect.any(Element))
+ })
+ await expect.element(page.getByText(COMPONENT_TEXT)).toBeInTheDocument()
+ expect(refIsNotAPropWarnings(consoleErrorMock)).toHaveLength(0)
+ expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
+ })
+ })
})