Skip to content

Commit ebe35f2

Browse files
mattgperryclaude
andcommitted
Throw an actionable invariant for non-DOM custom component refs
Bailing silently out of renderHTML kept the frame loop alive but left motion.create() components that don't forward a ref to an element doing nothing at all, with no indication why. Throw a "custom-component-ref" invariant at mount instead, pointing at https://motion.dev/troubleshooting/custom-component-ref. This surfaces in React with a component stack, so the offending component is named. The renderHTML bail stays for production, where invariants are no-ops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 86c7b94 commit ebe35f2

4 files changed

Lines changed: 81 additions & 17 deletions

File tree

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import * as React from "react"
22
import { motion } from "../.."
3-
import { nextFrame } from "../../gestures/__tests__/utils"
43
import { render } from "../../jest.setup"
54

65
/**
76
* Regression test for #2777
87
*
9-
* When `motion()` wraps a custom component whose ref does not resolve to a DOM
10-
* element (e.g. the inner component is a class component, so its ref is the
11-
* class instance rather than a styleable element), the render loop would throw
12-
* `Cannot convert undefined or null to object` / `Cannot set properties of
13-
* undefined` when trying to write styles to `instance.style`.
8+
* When `motion.create()` wraps a custom component whose ref doesn't resolve to
9+
* a DOM element (e.g. the inner component is a class component, so its ref is
10+
* the class instance rather than a styleable element), the render loop would
11+
* throw `Cannot convert undefined or null to object` from deep inside the
12+
* frame loop, breaking every animation on the page.
1413
*
15-
* Motion should not crash the whole frame loop in this case.
14+
* Motion should instead throw an actionable invariant at mount.
1615
*/
17-
describe("motion() wrapping a custom component with a non-DOM ref", () => {
18-
test("does not throw when the inner ref is not a DOM element", async () => {
16+
describe("motion.create() wrapping a custom component with a non-DOM ref", () => {
17+
let consoleError: jest.SpyInstance
18+
19+
beforeEach(() => {
20+
// React logs uncaught errors thrown from refs, which is noise here.
21+
consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
22+
})
23+
24+
afterEach(() => consoleError.mockRestore())
25+
26+
test("throws an actionable invariant when the ref isn't an element", () => {
1927
class ClassButton extends React.Component<any> {
2028
render() {
2129
return <button>{this.props.children}</button>
@@ -30,11 +38,22 @@ describe("motion() wrapping a custom component with a non-DOM ref", () => {
3038

3139
const MotionButton = motion.create(AnimateButton)
3240

33-
expect(() => {
41+
expect(() =>
3442
render(<MotionButton initial={{ opacity: 0 }}>BUY</MotionButton>)
35-
}).not.toThrow()
43+
).toThrowError(
44+
"motion.create() components must forward their ref to a HTML or SVG element. For more information and steps for solving, visit https://motion.dev/troubleshooting/custom-component-ref"
45+
)
46+
})
3647

37-
await nextFrame()
38-
await nextFrame()
48+
test("mounts without error when the ref resolves to an element", () => {
49+
const AnimateButton = React.forwardRef<HTMLButtonElement, any>(
50+
(props, ref) => <button ref={ref} {...props} />
51+
)
52+
53+
const MotionButton = motion.create(AnimateButton)
54+
55+
expect(() =>
56+
render(<MotionButton initial={{ opacity: 0 }}>BUY</MotionButton>)
57+
).not.toThrow()
3958
})
4059
})

โ€Žpackages/motion-dom/src/render/html/HTMLVisualElement.tsโ€Ž

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Box } from "motion-utils"
1+
import { invariant, type Box } from "motion-utils"
22
import type { AnyResolvedKeyframe } from "../../animation/types"
33
import { isCSSVariableName } from "../../animation/utils/is-css-variable"
44
import type { MotionNodeOptions } from "../../node/types"
@@ -28,6 +28,21 @@ export class HTMLVisualElement extends DOMVisualElement<
2828
> {
2929
type = "html"
3030

31+
mount(instance: HTMLElement) {
32+
/**
33+
* If a custom component forwards its ref to something other than a
34+
* HTML/SVG element (a class instance, an imperative handle) there's
35+
* nothing for Motion to style, measure or attach gestures to.
36+
*/
37+
invariant(
38+
Boolean(instance.style),
39+
"motion.create() components must forward their ref to a HTML or SVG element",
40+
"custom-component-ref"
41+
)
42+
43+
super.mount(instance)
44+
}
45+
3146
readValueFromInstance(
3247
instance: HTMLElement,
3348
key: string
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { HTMLRenderState } from "../../types"
2+
import { renderHTML } from "../render"
3+
4+
const createRenderState = (): HTMLRenderState => ({
5+
transform: {},
6+
transformOrigin: {},
7+
style: { opacity: 0.5 },
8+
vars: { "--test": "1" },
9+
})
10+
11+
describe("renderHTML", () => {
12+
test("renders styles and CSS variables to the element", () => {
13+
const element = document.createElement("div")
14+
15+
renderHTML(element, createRenderState())
16+
17+
expect(element.style.opacity).toBe("0.5")
18+
expect(element.style.getPropertyValue("--test")).toBe("1")
19+
})
20+
21+
/**
22+
* In development this is caught by the "custom-component-ref" invariant at
23+
* mount. In production we must not take the whole render loop down. #2777
24+
*/
25+
test("bails out if the instance isn't styleable", () => {
26+
const instance = {} as HTMLElement
27+
28+
expect(() => renderHTML(instance, createRenderState())).not.toThrow()
29+
})
30+
})

โ€Žpackages/motion-dom/src/render/html/utils/render.tsโ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export function renderHTML(
99
) {
1010
const elementStyle = element.style
1111

12-
// If the rendered instance isn't a styleable element (e.g. a custom
13-
// component forwarded its ref to a non-DOM instance), there's nothing to
14-
// render to. Bailing out here keeps the render loop from crashing. #2777
12+
// A non-styleable instance throws a "custom-component-ref" invariant on
13+
// mount in development. In production we bail rather than take the whole
14+
// render loop down with it. #2777
1515
if (!elementStyle) return
1616

1717
let key: string

0 commit comments

Comments
ย (0)