diff --git a/dev/react/src/tests/animate-presence-pop-ref.tsx b/dev/react/src/tests/animate-presence-pop-ref.tsx new file mode 100644 index 0000000000..1543c65c90 --- /dev/null +++ b/dev/react/src/tests/animate-presence-pop-ref.tsx @@ -0,0 +1,113 @@ +import { AnimatePresence, motion } from "framer-motion" +import { forwardRef, useImperativeHandle, useRef, useState } from "react" + +const containerStyles = { + position: "relative" as const, + display: "flex", + flexDirection: "column" as const, + padding: "100px", +} + +const boxStyles = { + width: "100px", + height: "100px", + backgroundColor: "red", +} + +interface BoxHandle { + flash: () => void +} + +/** + * Test component that uses forwardRef and useImperativeHandle + * This triggers the React 19 ref warning in PopChild + */ +const Box = forwardRef( + function Box({ id, style }, ref) { + const elementRef = useRef(null) + + useImperativeHandle(ref, () => ({ + flash: () => { + if (elementRef.current) { + elementRef.current.style.opacity = "0.5" + setTimeout(() => { + if (elementRef.current) { + elementRef.current.style.opacity = "1" + } + }, 100) + } + }, + })) + + return ( + + ) + } +) + +/** + * Another test: Component with direct ref forwarding to motion element + */ +const BoxWithDirectRef = forwardRef( + function BoxWithDirectRef({ id, style }, ref) { + return ( + + ) + } +) + +export const App = () => { + const [state, setState] = useState(true) + const params = new URLSearchParams(window.location.search) + const testType = params.get("type") || "imperative" + + const boxRef = useRef(null) + const directRef = useRef(null) + + return ( +
setState(!state)}> + + + {state ? ( + testType === "imperative" ? ( + + ) : ( + + ) + ) : null} + +
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/animate-presence-pop-ref.ts b/packages/framer-motion/cypress/integration/animate-presence-pop-ref.ts new file mode 100644 index 0000000000..37bc729692 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-pop-ref.ts @@ -0,0 +1,75 @@ +describe("AnimatePresence popLayout with refs", () => { + it("does not trigger React 19 ref warning with forwardRef components using useImperativeHandle", () => { + const consoleErrors: string[] = [] + + cy.visit("?test=animate-presence-pop-ref", { + onBeforeLoad(win) { + // Capture console errors and warnings + cy.stub(win.console, "error").callsFake((msg) => { + consoleErrors.push(String(msg)) + }) + cy.stub(win.console, "warn").callsFake((msg) => { + consoleErrors.push(String(msg)) + }) + }, + }) + .wait(200) + // Verify at least the static element exists (always rendered) + .get("#static") + .should("exist") + // Click to trigger removal and popLayout behavior + .get("#static") + .parent() + .trigger("click", 60, 60, { force: true }) + .wait(200) + // Verify no React 19 ref warning was logged + .then(() => { + const refWarning = consoleErrors.find( + (msg) => + msg.includes("element.ref") || + msg.includes("Accessing element.ref") + ) + expect( + refWarning, + "Should not have React 19 ref warning" + ).to.be.undefined + }) + }) + + it("does not trigger React 19 ref warning with forwardRef components with direct ref", () => { + const consoleErrors: string[] = [] + + cy.visit("?test=animate-presence-pop-ref&type=direct", { + onBeforeLoad(win) { + // Capture console errors and warnings + cy.stub(win.console, "error").callsFake((msg) => { + consoleErrors.push(String(msg)) + }) + cy.stub(win.console, "warn").callsFake((msg) => { + consoleErrors.push(String(msg)) + }) + }, + }) + .wait(200) + // Verify at least the static element exists (always rendered) + .get("#static") + .should("exist") + // Click to trigger removal + .get("#static") + .parent() + .trigger("click", 60, 60, { force: true }) + .wait(200) + // Verify no React 19 ref warning was logged + .then(() => { + const refWarning = consoleErrors.find( + (msg) => + msg.includes("element.ref") || + msg.includes("Accessing element.ref") + ) + expect( + refWarning, + "Should not have React 19 ref warning" + ).to.be.undefined + }) + }) +}) diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index f34e757ffb..6e2cfabe6a 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -72,10 +72,14 @@ export function PopChild({ children, isPresent, anchorX, root }: Props) { right: 0, }) const { nonce } = useContext(MotionConfigContext) - const composedRef = useComposedRefs( - ref, - (children as { ref?: React.Ref })?.ref - ) + /** + * In React 19, refs are passed via props.ref instead of element.ref. + * We check props.ref first (React 19) and fall back to element.ref (React 18). + */ + const childRef = + (children.props as { ref?: React.Ref })?.ref ?? + (children as unknown as { ref?: React.Ref })?.ref + const composedRef = useComposedRefs(ref, childRef) /** * We create and inject a style block so we can apply this explicit