Skip to content

fix: don't crash render loop for custom components with non-DOM refs#3755

Open
mattgperry wants to merge 1 commit into
mainfrom
fix-issue-2777
Open

fix: don't crash render loop for custom components with non-DOM refs#3755
mattgperry wants to merge 1 commit into
mainfrom
fix-issue-2777

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Bug

Fixes #2777

Wrapping a custom component with motion() could crash the entire animation frame loop with:

Uncaught TypeError: Cannot convert undefined or null to object
    at Function.assign (<anonymous>)
    at HTMLVisualElement.renderHTML [as renderInstance]
    at VisualElement.render
    at processBatch

The reporter hit this wrapping NextUI's Button:

const AnimateButton = React.forwardRef((props, ref) => (
    <Button ref={ref} {...props} />
))
export const MotionButton = motion(AnimateButton)

Cause

When motion() wraps a custom component, it creates an HTMLVisualElement and mounts whatever the forwarded ref resolves 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.

renderHTML then writes the built styles to instance.style, which is undefined. The original Object.assign(element.style, …) threw Cannot convert undefined or null to object; the later for..in refactor turned this into Cannot set properties of undefined once 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 renderHTML when the instance has no style. 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.tsx reproduces the reporter's scenario — a custom forwardRef component 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-dom rebuilt manually (turbo build is unreliable in worktrees); bundle-size check passes.
  • motion-dom suite: 471 passed.
  • framer-motion client 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 bare framer-motion self-import); they fail identically without this change and are unrelated.

🤖 Generated with Claude Code

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-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

Guards the HTML render path against non-DOM refs by bailing out of renderHTML early when element.style is absent, preventing the render loop from crashing when motion() wraps a custom component whose forwarded ref resolves to a class instance rather than a real DOM element.

  • packages/motion-dom/src/render/html/utils/render.ts: Adds if (!elementStyle) return before the style/vars write loops; real HTML/SVG elements always have .style, so this is a no-op for valid elements.
  • packages/framer-motion/src/motion/__tests__/custom-non-dom-ref.test.tsx: New regression test that mirrors the NextUI Button reproduction — a forwardRef wrapper pointing to a class component — verifying the frame loop does not throw.

Confidence Score: 4/5

Safe 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 .style.

The HTML render fix is correct and self-contained. The parallel SVG render path (renderSVG) is not protected by the same guard — it still calls element.setAttribute after renderHTML returns early, which would throw if an SVG custom component ever forwards its ref to a non-DOM instance.

packages/motion-dom/src/render/svg/utils/render.ts — the element.setAttribute loop after the renderHTML call has no equivalent guard for non-DOM refs.

Important Files Changed

Filename Overview
packages/motion-dom/src/render/html/utils/render.ts Adds a one-line early-return guard when element.style is falsy, preventing the render loop from crashing when a custom component's forwarded ref resolves to a non-DOM instance. Fix is minimal, targeted, and correct.
packages/framer-motion/src/motion/tests/custom-non-dom-ref.test.tsx New regression test reproducing #2777 via a class-component ref that has no .style. Correctly mirrors the reporter's NextUI scenario. Frame-loop error coverage relies on Jest's global uncaught-exception handler rather than an explicit assertion.

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
Loading

Reviews (1): Last reviewed commit: "fix: don't crash render loop for custom ..." | Re-trigger Greptile

Comment on lines +33 to +39
expect(() => {
render(<MotionButton initial={{ opacity: 0 }}>BUY</MotionButton>)
}).not.toThrow()

await nextFrame()
await nextFrame()
})

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Motion custom component throw error: Cannot convert undefined or null to object

1 participant