diff --git a/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx b/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx new file mode 100644 index 0000000000..1c361f59db --- /dev/null +++ b/packages/framer-motion/src/motion/utils/__tests__/use-motion-ref.test.tsx @@ -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 + } + + render() + + 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 + } + + const { unmount } = render() + + // 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 + } + + const { unmount } = render() + + // 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(null) + return + } + + const { unmount } = render() + + // 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(null) + return + } + + const { rerender } = render() + + expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement)) + + // Should handle transition between ref types without errors + expect(() => rerender()).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 ( + + ) + } + + const { unmount } = render() + + // 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( + (props, ref) => { + return + } + ) + + const Component = () => { + return + } + + const { unmount } = render() + + expect(refCallback).toHaveBeenCalledWith(expect.any(HTMLElement)) + + // Clear previous calls + refCallback.mockClear() + cleanup.mockClear() + + unmount() + + expect(cleanup).toHaveBeenCalledTimes(1) + expect(refCallback).not.toHaveBeenCalledWith(null) + }) +}) diff --git a/packages/framer-motion/src/motion/utils/use-motion-ref.ts b/packages/framer-motion/src/motion/utils/use-motion-ref.ts index 58db52036a..e756b04b8c 100644 --- a/packages/framer-motion/src/motion/utils/use-motion-ref.ts +++ b/packages/framer-motion/src/motion/utils/use-motion-ref.ts @@ -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(ref: React.Ref, 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. @@ -15,6 +28,9 @@ export function useMotionRef( visualElement?: VisualElement | null, externalRef?: React.Ref ): React.Ref { + // Store the cleanup function from external ref if it returns one + const externalRefCleanupRef = useRef<(() => void) | null>(null) + return useCallback( (instance: Instance) => { if (instance) { @@ -30,17 +46,27 @@ export function useMotionRef( } 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] ) }