fix: don't crash render loop for custom components with non-DOM refs#3755
fix: don't crash render loop for custom components with non-DOM refs#3755mattgperry wants to merge 1 commit into
Conversation
When motion() wraps a custom component whose ref resolves to a non-DOM instance (e.g. an inner class component, as with NextUI's Button), the render loop threw "Cannot convert undefined or null to object" / "Cannot set properties of undefined" when writing styles to `instance.style`. Guard renderHTML to bail out when the instance has no `style`, keeping the frame loop alive. Fixes #2777 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryGuards the HTML render path against non-DOM refs by bailing out of
Confidence Score: 4/5Safe to merge — the core fix is a one-line guard in a hot render path with no risk of regressing real DOM elements, since they always have The HTML render fix is correct and self-contained. The parallel SVG render path ( packages/motion-dom/src/render/svg/utils/render.ts — the Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["motion(CustomComponent) renders"] --> B["HTMLVisualElement.renderInstance()"]
B --> C["renderHTML(element, renderState, ...)"]
C --> D{element.style exists?}
D -- "No (class instance ref)" --> E["return early
(was: crash)"]
D -- "Yes (real DOM element)" --> F["Write style props
Write CSS vars
Apply projection styles"]
F --> G["Frame loop continues normally"]
E --> G
Reviews (1): Last reviewed commit: "fix: don't crash render loop for custom ..." | Re-trigger Greptile |
| expect(() => { | ||
| render(<MotionButton initial={{ opacity: 0 }}>BUY</MotionButton>) | ||
| }).not.toThrow() | ||
|
|
||
| await nextFrame() | ||
| await nextFrame() | ||
| }) |
There was a problem hiding this comment.
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.
Bug
Fixes #2777
Wrapping a custom component with
motion()could crash the entire animation frame loop with:The reporter hit this wrapping NextUI's
Button:Cause
When
motion()wraps a custom component, it creates anHTMLVisualElementand mounts whatever the forwardedrefresolves to. If that inner component does not forward its ref to a real DOM element (e.g. it's a class component, so the ref is the class instance), motion mounts a non-DOM object that has no.style.renderHTMLthen writes the built styles toinstance.style, which isundefined. The originalObject.assign(element.style, …)threwCannot convert undefined or null to object; the laterfor..inrefactor turned this intoCannot set properties of undefinedonce any style is present. Because this throws inside the batched render step, it takes down the whole frame loop — every animation on the page breaks, not just the offending element.Fix
Bail out of
renderHTMLwhen the instance has nostyle. A non-styleable instance simply receives no styles instead of crashing the render loop. Real HTML/SVG elements always have.style, so this is a no-op for every valid element.Test
packages/framer-motion/src/motion/__tests__/custom-non-dom-ref.test.tsxreproduces the reporter's scenario — a customforwardRefcomponent whose ref resolves to a class-component instance. It produces the exact stack trace from the issue (renderHTML → VisualElement.render → triggerCallback → processBatch) and was verified to fail for the right reason before the fix and pass after.Verification
motion-domrebuilt manually (turbo build is unreliable in worktrees); bundle-size check passes.motion-domsuite: 471 passed.framer-motionclient suite: 776 passed. The only 2 failing suites (component.test,types.test) fail at import time on a pre-existing worktree limitation (jest can't resolve the bareframer-motionself-import); they fail identically without this change and are unrelated.🤖 Generated with Claude Code