Skip to content

Commit 93f0688

Browse files
balzssclaude
andcommitted
fix(ui-motion): only pass elementRef to children that declare it
BaseTransition treated `typeof child.type === 'object'` as "withStyle-decorated InstUI component". That also matches emotion's wrapper around any element with a `css` prop, and emotion forwards unknown props to the DOM node: React does not recognize the `elementRef` prop on a DOM element. Tray, DrawerTray, RatingIcon v2 and Modal (constrain="parent") all render such a child. Introduced in aaa4a58 (#2618), first shipped in v11.7.4; reported by canvas-lms, where it failed 380 canvas-rce tests. Gate the elementRef branch on the child declaring `elementRef` in `allowedProps` instead. This is not a revert — #2618 fixed LX-4014, where a withStyle child plus a running transition made React read `ref` off the element, and reverting brings that back. Tests now cover both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3a56f60 commit 93f0688

2 files changed

Lines changed: 115 additions & 25 deletions

File tree

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

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -341,32 +341,35 @@ class BaseTransition extends Component<
341341

342342
const child = ensureSingleChild(this.props.children) as ReactElement
343343

344-
const elementOnlyRef = (el: ReactInstance | Element | null) => {
345-
if (el instanceof Element) {
346-
this.handleRef(el)
347-
}
348-
}
349-
350-
// `typeof type === 'object'` => forwardRef wrapper (withStyle-decorated InstUI components)
351-
const refProps =
352-
typeof child.type === 'object'
353-
? {
354-
// chain so the child's own elementRef still fires instead of being overwritten
355-
elementRef: createChainedFunction(
356-
(child.props as { elementRef?: (el: Element | null) => void })
357-
?.elementRef,
358-
this.handleRef
359-
),
360-
// fallback for forwardRef children that expose their node via `ref`, not elementRef
361-
ref: elementOnlyRef
362-
}
363-
: {
364-
// for host el / plain class|fn: findDOMNode is the fallback
365-
ref: (el: ReactInstance | Element | null) =>
366-
this.handleRef(
367-
el instanceof Element ? el : (findDOMNode(el) as Element) ?? null
368-
)
344+
// Pass elementRef only to children that declare it. Emotion's wrapper
345+
// around `<div css={...}>` accepts any prop and forwards it to the DOM
346+
// node, so it must take the plain `ref` path.
347+
const acceptsElementRef = (
348+
child.type as { allowedProps?: readonly string[] }
349+
)?.allowedProps?.includes('elementRef')
350+
351+
const refProps = acceptsElementRef
352+
? {
353+
// chain so the child's own elementRef still fires instead of being overwritten
354+
elementRef: createChainedFunction(
355+
(child.props as { elementRef?: (el: Element | null) => void })
356+
?.elementRef,
357+
this.handleRef
358+
),
359+
// fallback for forwardRef children that expose their node via `ref`, not elementRef
360+
ref: (el: ReactInstance | Element | null) => {
361+
if (el instanceof Element) {
362+
this.handleRef(el)
363+
}
369364
}
365+
}
366+
: {
367+
// host el / plain class|fn: findDOMNode is the fallback
368+
ref: (el: ReactInstance | Element | null) =>
369+
this.handleRef(
370+
el instanceof Element ? el : (findDOMNode(el) as Element) ?? null
371+
)
372+
}
370373

371374
return safeCloneElement(child, {
372375
'aria-hidden': !this.props.in ? true : undefined,

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@
2323
*/
2424

2525
import { Component, createRef, RefObject } from 'react'
26+
import type { ComponentType } from 'react'
2627
import { render } from 'vitest-browser-react'
2728
import { page } from 'vitest/browser'
2829
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2930
import type { MockInstance } from 'vitest'
3031

32+
import { withStyle } from '@instructure/emotion'
33+
3134
import { Transition } from '../index.js'
3235
import { getClassNames } from '../styles.js'
3336

@@ -55,6 +58,22 @@ class ExampleComponent extends Component<any, any> {
5558
}
5659
}
5760

61+
// stands in for a real InstUI component, which ui-motion can't import (they
62+
// depend on it)
63+
type StyledChildProps = { elementRef?: (el: Element | null) => void }
64+
65+
class StyledChildBase extends Component<StyledChildProps> {
66+
static allowedProps = ['elementRef']
67+
render() {
68+
return <div ref={this.props.elementRef}>{COMPONENT_TEXT}</div>
69+
}
70+
}
71+
72+
const StyledChild = withStyle(
73+
() => ({}),
74+
() => ({})
75+
)(StyledChildBase) as unknown as ComponentType<StyledChildProps>
76+
5877
describe('<Transition />', () => {
5978
let consoleWarningMock: ReturnType<typeof vi.spyOn>
6079
let consoleErrorMock: ReturnType<typeof vi.spyOn>
@@ -271,4 +290,72 @@ describe('<Transition />', () => {
271290
expect(onExited).toHaveBeenCalled()
272291
})
273292
})
293+
294+
describe('capturing the child node', () => {
295+
const warningsMatching = (mock: MockInstance, pattern: RegExp) =>
296+
mock.mock.calls.filter((args: unknown[]) => pattern.test(args.join(' ')))
297+
298+
const elementRefWarnings = (mock: MockInstance) =>
299+
warningsMatching(mock, /elementRef/)
300+
301+
const refIsNotAPropWarnings = (mock: MockInstance) =>
302+
warningsMatching(mock, /`?ref`? is not a prop/)
303+
304+
it('does not leak elementRef onto an emotion-wrapped host element', async () => {
305+
await render(
306+
<Transition type="fade" in={true}>
307+
<div css={{ color: 'red' }}>hello</div>
308+
</Transition>
309+
)
310+
const element = page.getByText('hello').element()
311+
312+
expect(element).not.toHaveAttribute('elementref')
313+
expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
314+
})
315+
316+
it('still captures an emotion-wrapped host element', async () => {
317+
const elementRef = vi.fn()
318+
await render(
319+
<Transition type="fade" in={true} elementRef={elementRef}>
320+
<div css={{ color: 'red' }}>hello</div>
321+
</Transition>
322+
)
323+
324+
// the node reached handleRef, so the transition classes could be applied
325+
expect(page.getByText('hello').element()).toHaveClass(
326+
getClass('fade', 'entered')
327+
)
328+
await vi.waitFor(() => {
329+
expect(elementRef).toHaveBeenCalledWith(expect.any(Element))
330+
})
331+
})
332+
333+
// a withStyle child plus a running transition made React read `ref` off the
334+
// element; both conditions are needed to reproduce it
335+
it('does not read `ref` off a withStyle child mid-transition', async () => {
336+
const childElementRef = vi.fn()
337+
const transitionElementRef = vi.fn()
338+
339+
await render(
340+
<Transition
341+
type="fade"
342+
in={false}
343+
transitionOnMount
344+
elementRef={transitionElementRef}
345+
>
346+
<StyledChild elementRef={childElementRef} />
347+
</Transition>
348+
)
349+
350+
await vi.waitFor(() => {
351+
// the child's own elementRef is chained, not overwritten, and
352+
// Transition still captured the node
353+
expect(childElementRef).toHaveBeenCalledWith(expect.any(Element))
354+
expect(transitionElementRef).toHaveBeenCalledWith(expect.any(Element))
355+
})
356+
await expect.element(page.getByText(COMPONENT_TEXT)).toBeInTheDocument()
357+
expect(refIsNotAPropWarnings(consoleErrorMock)).toHaveLength(0)
358+
expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
359+
})
360+
})
274361
})

0 commit comments

Comments
 (0)