Skip to content

Commit 56fa2c9

Browse files
balzssclaude
andcommitted
fix(ui-motion): only pass elementRef to children that declare it
BaseTransition treated `typeof child.type === 'object'` as "this is a withStyle-decorated InstUI component". That's true of every forwardRef wrapper, including the one emotion's jsx runtime wraps around any element carrying a `css` prop — and emotion forwards unknown props straight 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, so every tray and dialog built on them logged this. Introduced in aaa4a58 (#2618), first shipped in v11.7.4; reported by canvas-lms, where it failed 380 canvas-rce tests. Narrow the check to children that actually declare `elementRef` in `allowedProps`. Emotion's wrapper doesn't, so `<div css={...}>` falls back to the plain `ref` path, while a withStyle child keeps the chained `elementRef` that #2618 added. Note this is deliberately not a revert. #2618 fixed LX-4014, where a withStyle child plus a running transition made React read `ref` off the element and warn. Reverting `renderChildren` brings that back — verified, not assumed. Both behaviours are now pinned by tests that bracket the fix: the leak test fails under the old `typeof` check, and the LX-4014 test fails under a plain revert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 30c7251 commit 56fa2c9

2 files changed

Lines changed: 122 additions & 25 deletions

File tree

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

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -341,32 +341,38 @@ 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+
// InstUI components expose an `elementRef` prop, which hands back the DOM
345+
// node without React having to read `ref` off the element (which warns).
346+
// Only children that actually declare it can be given one: `typeof
347+
// child.type === 'object'` is true of every forwardRef wrapper, including
348+
// the one emotion wraps `<div css={...}>` in, and emotion forwards unknown
349+
// props straight to the DOM node.
350+
const acceptsElementRef = (
351+
child.type as { allowedProps?: readonly string[] }
352+
)?.allowedProps?.includes('elementRef')
353+
354+
const refProps = acceptsElementRef
355+
? {
356+
// chain so the child's own elementRef still fires instead of being overwritten
357+
elementRef: createChainedFunction(
358+
(child.props as { elementRef?: (el: Element | null) => void })
359+
?.elementRef,
360+
this.handleRef
361+
),
362+
// fallback for forwardRef children that expose their node via `ref`, not elementRef
363+
ref: (el: ReactInstance | Element | null) => {
364+
if (el instanceof Element) {
365+
this.handleRef(el)
366+
}
369367
}
368+
}
369+
: {
370+
// host el / plain class|fn: findDOMNode is the fallback
371+
ref: (el: ReactInstance | Element | null) =>
372+
this.handleRef(
373+
el instanceof Element ? el : (findDOMNode(el) as Element) ?? null
374+
)
375+
}
370376

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

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
*/
2424

2525
import { Component, createRef, RefObject } from 'react'
26+
import type { ComponentType } from 'react'
2627
import {
2728
render,
2829
waitFor,
@@ -32,6 +33,8 @@ import { vi } from 'vitest'
3233
import type { MockInstance } from 'vitest'
3334
import '@testing-library/jest-dom'
3435

36+
import { withStyle } from '@instructure/emotion'
37+
3538
import { Transition } from '../index.js'
3639
import { getClassNames } from '../styles.js'
3740

@@ -59,6 +62,23 @@ class ExampleComponent extends Component<any, any> {
5962
}
6063
}
6164

65+
// Stands in for an InstUI component: `withStyle`-decorated, and declaring
66+
// `elementRef` in `allowedProps` the way real components do. ui-motion can't
67+
// import one directly — ui-alerts and friends depend on ui-motion.
68+
type StyledChildProps = { elementRef?: (el: Element | null) => void }
69+
70+
class StyledChildBase extends Component<StyledChildProps> {
71+
static allowedProps = ['elementRef']
72+
render() {
73+
return <div ref={this.props.elementRef}>{COMPONENT_TEXT}</div>
74+
}
75+
}
76+
77+
const StyledChild = withStyle(
78+
() => ({}),
79+
() => ({})
80+
)(StyledChildBase) as unknown as ComponentType<StyledChildProps>
81+
6282
describe('<Transition />', () => {
6383
let consoleWarningMock: ReturnType<typeof vi.spyOn>
6484
let consoleErrorMock: ReturnType<typeof vi.spyOn>
@@ -275,4 +295,75 @@ describe('<Transition />', () => {
275295
expect(onExited).toHaveBeenCalled()
276296
})
277297
})
298+
299+
describe('capturing the child node', () => {
300+
const warningsMatching = (mock: MockInstance, pattern: RegExp) =>
301+
mock.mock.calls.filter((args: unknown[]) => pattern.test(args.join(' ')))
302+
303+
const elementRefWarnings = (mock: MockInstance) =>
304+
warningsMatching(mock, /elementRef/)
305+
306+
const refIsNotAPropWarnings = (mock: MockInstance) =>
307+
warningsMatching(mock, /`?ref`? is not a prop/)
308+
309+
// The repo compiles JSX with `jsxImportSource: '@emotion/react'`, so a child
310+
// written with a `css` prop is rendered through emotion's own forwardRef
311+
// wrapper. These children are the realistic case — Tray, DrawerTray, Modal
312+
// and Alert all render one.
313+
it('does not leak elementRef onto an emotion-wrapped host element', async () => {
314+
const { getByText } = render(
315+
<Transition type="fade" in={true}>
316+
<div css={{ color: 'red' }}>hello</div>
317+
</Transition>
318+
)
319+
const element = getByText('hello')
320+
321+
expect(element).not.toHaveAttribute('elementref')
322+
expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
323+
})
324+
325+
it('still captures an emotion-wrapped host element', async () => {
326+
const elementRef = vi.fn()
327+
const { getByText } = render(
328+
<Transition type="fade" in={true} elementRef={elementRef}>
329+
<div css={{ color: 'red' }}>hello</div>
330+
</Transition>
331+
)
332+
333+
// the node reached handleRef, so the transition classes could be applied
334+
expect(getByText('hello')).toHaveClass(getClass('fade', 'entered'))
335+
await waitFor(() => {
336+
expect(elementRef).toHaveBeenCalledWith(expect.any(Element))
337+
})
338+
})
339+
340+
// LX-4014 (#2618): a withStyle child plus an actually-running transition
341+
// made React read `ref` off the element. Both conditions are required — a
342+
// host child, or a mount that never transitions, won't reproduce it.
343+
it('does not read `ref` off a withStyle child mid-transition', async () => {
344+
const childElementRef = vi.fn()
345+
const transitionElementRef = vi.fn()
346+
347+
const { getByText } = render(
348+
<Transition
349+
type="fade"
350+
in={false}
351+
transitionOnMount
352+
elementRef={transitionElementRef}
353+
>
354+
<StyledChild elementRef={childElementRef} />
355+
</Transition>
356+
)
357+
358+
await waitFor(() => {
359+
// the child's own elementRef is chained, not overwritten, and
360+
// Transition still captured the node
361+
expect(childElementRef).toHaveBeenCalledWith(expect.any(Element))
362+
expect(transitionElementRef).toHaveBeenCalledWith(expect.any(Element))
363+
})
364+
expect(getByText(COMPONENT_TEXT)).toBeInTheDocument()
365+
expect(refIsNotAPropWarnings(consoleErrorMock)).toHaveLength(0)
366+
expect(elementRefWarnings(consoleErrorMock)).toHaveLength(0)
367+
})
368+
})
278369
})

0 commit comments

Comments
 (0)