Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from "react"
import { motion } from "../.."
import { nextFrame } from "../../gestures/__tests__/utils"
import { render } from "../../jest.setup"

/**
* Regression test for #2777
*
* When `motion()` wraps a custom component whose ref does not resolve to a DOM
* element (e.g. the inner component is a class component, so its ref is the
* class instance rather than a styleable element), the render loop would throw
* `Cannot convert undefined or null to object` / `Cannot set properties of
* undefined` when trying to write styles to `instance.style`.
*
* Motion should not crash the whole frame loop in this case.
*/
describe("motion() wrapping a custom component with a non-DOM ref", () => {
test("does not throw when the inner ref is not a DOM element", async () => {
class ClassButton extends React.Component<any> {
render() {
return <button>{this.props.children}</button>
}
}

// Forwards the ref to a class component, so the mounted instance is the
// class instance (no `.style`), mirroring the NextUI Button repro.
const AnimateButton = React.forwardRef<any, any>((props, ref) => (
<ClassButton ref={ref} {...props} />
))

const MotionButton = motion.create(AnimateButton)

expect(() => {
render(<MotionButton initial={{ opacity: 0 }}>BUY</MotionButton>)
}).not.toThrow()

await nextFrame()
await nextFrame()
})
Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Frame-loop errors not covered by the not.toThrow assertion

The expect(() => render(...)).not.toThrow() wrapper only covers the synchronous initial render. The actual crash (renderHTML → VisualElement.render → processBatch) happens asynchronously inside the requestAnimationFrame callback that runs during the two await nextFrame() calls. In Node.js/JSDOM, uncaught errors thrown inside setTimeout-based rAF polyfills may surface as uncaught exceptions that Jest catches globally, but this is not guaranteed across all jest-environment-jsdom configurations. Wrapping the nextFrame() calls in a try/await/catch and re-asserting, or adding an expect.assertions(0) guard, would make the intent unambiguous and more robust across JSDOM versions.

})
5 changes: 5 additions & 0 deletions packages/motion-dom/src/render/html/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export function renderHTML(
) {
const elementStyle = element.style

// If the rendered instance isn't a styleable element (e.g. a custom
// component forwarded its ref to a non-DOM instance), there's nothing to
// render to. Bailing out here keeps the render loop from crashing. #2777
if (!elementStyle) return

let key: string
for (key in style) {
// CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type.
Expand Down