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,149 @@
import * as React from "react"
import { useRef } from "react"
import { motion } from "../../.."
import { render } from "../../../jest.setup"

describe("useMotionRef", () => {
it("should call external ref callback with element on mount", () => {
const refCallback = jest.fn()

const Component = () => {
return <motion.div ref={refCallback} />
}

render(<Component />)

expect(refCallback).toHaveBeenCalledTimes(1)
expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement))
})

it("should call external ref callback with null on unmount (React 18 behavior)", () => {
const refCallback = jest.fn()

const Component = () => {
return <motion.div ref={refCallback} />
}

const { unmount } = render(<Component />)

// Clear previous calls
refCallback.mockClear()

unmount()

expect(refCallback).toHaveBeenCalledTimes(1)
expect(refCallback).toHaveBeenCalledWith(null)
})

it("should support React 19 cleanup function pattern (forward compatibility)", () => {
// This test verifies that when a ref callback returns a cleanup function,
// our code properly stores it and calls it on unmount instead of calling ref(null).
// This works in both React 18 and React 19 without warnings.
const cleanup = jest.fn()
const refCallback = jest.fn(() => cleanup)

const Component = () => {
return <motion.div ref={refCallback} />
}

const { unmount } = render(<Component />)

// Verify mount called correctly
expect(refCallback).toHaveBeenCalledTimes(1)
expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement))

// Clear previous calls to focus on unmount behavior
refCallback.mockClear()
cleanup.mockClear()

unmount()

// With our new approach: cleanup function should be called
// and ref should NOT be called with null
expect(cleanup).toHaveBeenCalledTimes(1)
expect(refCallback).not.toHaveBeenCalledWith(null)
})

it("should handle RefObject refs correctly", () => {
const Component = () => {
const ref = useRef<HTMLDivElement>(null)
return <motion.div ref={ref} />
}

const { unmount } = render(<Component />)

// Should not throw on mount or unmount
expect(() => unmount()).not.toThrow()
})

it("should handle mixed ref types in motion components", () => {
const refCallback = jest.fn()

const Component = ({ useCallback }: { useCallback: boolean }) => {
const refObject = useRef<HTMLDivElement>(null)
return <motion.div ref={useCallback ? refCallback : refObject} />
}

const { rerender } = render(<Component useCallback={true} />)

expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement))

// Should handle transition between ref types without errors
expect(() => rerender(<Component useCallback={false} />)).not.toThrow()
})

it("should handle visual element cleanup correctly with React 19 pattern", () => {
const cleanup = jest.fn()
const refCallback = jest.fn(() => cleanup)

const Component = () => {
return (
<motion.div
ref={refCallback}
// Add motion props to ensure visual element is created
animate={{ x: 100 }}
/>
)
}

const { unmount } = render(<Component />)

// Clear previous calls
refCallback.mockClear()
cleanup.mockClear()

unmount()

// Both external ref cleanup and visual element unmount should happen
expect(cleanup).toHaveBeenCalledTimes(1)
expect(refCallback).not.toHaveBeenCalledWith(null)
})

it("should work with forwardRef components and React 19 cleanup pattern", () => {
const cleanup = jest.fn()
const refCallback = jest.fn(() => cleanup)

const ForwardedComponent = React.forwardRef<HTMLDivElement>(
(props, ref) => {
return <motion.div ref={ref} {...props} />
}
)

const Component = () => {
return <ForwardedComponent ref={refCallback} />
}

const { unmount } = render(<Component />)

expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement))

// Clear previous calls
refCallback.mockClear()
cleanup.mockClear()

unmount()

expect(cleanup).toHaveBeenCalledTimes(1)
expect(refCallback).not.toHaveBeenCalledWith(null)
})
})
42 changes: 34 additions & 8 deletions packages/framer-motion/src/motion/utils/use-motion-ref.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
"use client"

import * as React from "react"
import { useCallback } from "react"
import { useCallback, useRef } from "react"
import type { VisualElement } from "../../render/VisualElement"
import { isRefObject } from "../../utils/is-ref-object"
import { VisualState } from "./use-visual-state"

/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
* Returns a cleanup function if the ref callback returns one (React 19 feature)
*/
function setRef<T>(ref: React.Ref<T>, value: T): void | (() => void) {
if (typeof ref === "function") {
return ref(value)
} else if (isRefObject(ref)) {
;(ref as any).current = value
}
}

/**
* Creates a ref function that, when called, hydrates the provided
* external ref and VisualElement.
Expand All @@ -15,6 +28,9 @@ export function useMotionRef<Instance, RenderState>(
visualElement?: VisualElement<Instance> | null,
externalRef?: React.Ref<Instance>
): React.Ref<Instance> {
// Store the cleanup function from external ref if it returns one
const externalRefCleanupRef = useRef<(() => void) | null>(null)

return useCallback(
(instance: Instance) => {
if (instance) {
Expand All @@ -30,17 +46,27 @@ export function useMotionRef<Instance, RenderState>(
}

if (externalRef) {
if (typeof externalRef === "function") {
externalRef(instance)
} else if (isRefObject(externalRef)) {
;(externalRef as any).current = instance
if (instance) {
// Mount: call the external ref and store any cleanup function
const cleanup = setRef(externalRef, instance)
if (typeof cleanup === "function") {
externalRefCleanupRef.current = cleanup
}
} else {
// Unmount: call stored cleanup function if available, otherwise call ref with null
if (externalRefCleanupRef.current) {
externalRefCleanupRef.current()
externalRefCleanupRef.current = null
} else {
// Fallback to React <19 behavior for refs that don't return cleanup
setRef(externalRef, instance)
}
}
}
},
/**
* Include externalRef in dependencies to ensure the callback updates
* when the ref changes, allowing proper ref forwarding.
* Include all dependencies to ensure the callback updates correctly
*/
[visualElement]
[visualElement, visualState, externalRef]
)
}