Skip to content
Merged
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
1 change: 1 addition & 0 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
Expand Down
1 change: 1 addition & 0 deletions dev/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
Expand Down
120 changes: 120 additions & 0 deletions dev/react/src/tests/animate-presence-radix-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { AnimatePresence, motion } from "framer-motion"
import { useId, useState } from "react"

/**
* Test for AnimatePresence with Radix UI Dialog
* This reproduces issue #3455 where exit animations break
* when using asChild with motion components inside AnimatePresence.
*
* The issue occurs because Radix UI's asChild prop creates new callback refs
* on each render, and when externalRef is in the useMotionRef dependency array,
* this causes the callback to be recreated, triggering remounts that break
* exit animations.
*/
export const App = () => {
const id = useId()
const [isOpen, setIsOpen] = useState(false)
const [exitComplete, setExitComplete] = useState(false)
const [exitStarted, setExitStarted] = useState(false)

return (
<div className="App" style={{ padding: 20 }}>
<style>{`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
position: fixed;
background: white;
padding: 20px;
border-radius: 8px;
width: 300px;
}
`}</style>

<DialogPrimitive.Root onOpenChange={setIsOpen} open={isOpen}>
<DialogPrimitive.Trigger id="trigger">
{isOpen ? "Close" : "Open"}
</DialogPrimitive.Trigger>
<AnimatePresence
onExitComplete={() => setExitComplete(true)}
>
{isOpen ? (
<DialogPrimitive.Portal key={id} forceMount>
<DialogPrimitive.Overlay asChild>
<motion.div
id="overlay"
className="overlay"
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onAnimationStart={(definition) => {
if (
definition === "exit" ||
(typeof definition === "object" &&
"opacity" in definition &&
definition.opacity === 0)
) {
setExitStarted(true)
}
}}
/>
</DialogPrimitive.Overlay>

<DialogPrimitive.Content asChild>
<motion.div
id="dialog"
className="dialog"
animate={{
left: "50%",
bottom: "50%",
y: "50%",
x: "-50%",
}}
exit={{
left: "50%",
bottom: 0,
y: "100%",
x: "-50%",
}}
initial={{
left: "50%",
bottom: 0,
y: "100%",
x: "-50%",
}}
transition={{ duration: 0.3 }}
>
<DialogPrimitive.Title>
Dialog Title
</DialogPrimitive.Title>

<DialogPrimitive.Description>
Dialog content here
</DialogPrimitive.Description>

<DialogPrimitive.Close id="close">
Close
</DialogPrimitive.Close>
</motion.div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
) : null}
</AnimatePresence>
</DialogPrimitive.Root>

<div id="status" style={{ marginTop: 20 }}>
<div id="exit-started" data-value={exitStarted.toString()}>
Exit started: {exitStarted.toString()}
</div>
<div id="exit-complete" data-value={exitComplete.toString()}>
Exit complete: {exitComplete.toString()}
</div>
</div>
</div>
)
}
139 changes: 139 additions & 0 deletions dev/react/src/tests/motion-ref-forwarding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { motion } from "framer-motion"
import { useRef, useState, useCallback } from "react"

/**
* Test for ref forwarding behavior in motion components.
* Tests:
* 1. RefObject forwarding - ref.current should be set to the DOM element
* 2. Callback ref forwarding - callback should be called with DOM element on mount, null on unmount
* 3. Callback ref cleanup (React 19) - cleanup function should be called on unmount
*/
export const App = () => {
const [mounted, setMounted] = useState(true)
const [results, setResults] = useState({
refObjectMounted: false,
refObjectValue: "none",
callbackRefMountCalled: false,
callbackRefMountValue: "none",
callbackRefUnmountCalled: false,
callbackRefUnmountValue: "none",
cleanupCalled: false,
})

// Test 1: RefObject
const refObject = useRef<HTMLDivElement>(null)

// Test 2: Callback ref
const callbackRef = useCallback((instance: HTMLDivElement | null) => {
if (instance) {
setResults((prev) => ({
...prev,
callbackRefMountCalled: true,
callbackRefMountValue: instance.tagName,
}))
// Return cleanup function (React 19 feature)
return () => {
setResults((prev) => ({
...prev,
cleanupCalled: true,
}))
}
} else {
setResults((prev) => ({
...prev,
callbackRefUnmountCalled: true,
callbackRefUnmountValue: "null",
}))
}
}, [])

// Check refObject after mount
const checkRefObject = () => {
setResults((prev) => ({
...prev,
refObjectMounted: true,
refObjectValue: refObject.current?.tagName || "null",
}))
}

return (
<div style={{ padding: 20 }}>
<h2>Motion Ref Forwarding Test</h2>

<button id="toggle" onClick={() => setMounted(!mounted)}>
{mounted ? "Unmount" : "Mount"}
</button>
<button id="check-ref" onClick={checkRefObject}>
Check RefObject
</button>

{mounted && (
<>
<motion.div
id="ref-object-target"
ref={refObject}
style={{
width: 100,
height: 100,
background: "blue",
margin: 10,
}}
>
RefObject Target
</motion.div>

<motion.div
id="callback-ref-target"
ref={callbackRef}
style={{
width: 100,
height: 100,
background: "green",
margin: 10,
}}
>
Callback Ref Target
</motion.div>
</>
)}

<div id="results" style={{ marginTop: 20, fontFamily: "monospace" }}>
<div
id="ref-object-mounted"
data-value={results.refObjectMounted.toString()}
>
refObject checked: {results.refObjectMounted.toString()}
</div>
<div id="ref-object-value" data-value={results.refObjectValue}>
refObject.current?.tagName: {results.refObjectValue}
</div>
<div
id="callback-mount-called"
data-value={results.callbackRefMountCalled.toString()}
>
callback ref mount called:{" "}
{results.callbackRefMountCalled.toString()}
</div>
<div
id="callback-mount-value"
data-value={results.callbackRefMountValue}
>
callback ref mount value: {results.callbackRefMountValue}
</div>
<div
id="callback-unmount-called"
data-value={results.callbackRefUnmountCalled.toString()}
>
callback ref unmount called:{" "}
{results.callbackRefUnmountCalled.toString()}
</div>
<div
id="cleanup-called"
data-value={results.cleanupCalled.toString()}
>
cleanup function called: {results.cleanupCalled.toString()}
</div>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
describe("AnimatePresence with Radix UI Dialog", () => {
it("Exit animations work correctly with Radix Dialog asChild", () => {
cy.visit("?test=animate-presence-radix-dialog")
.wait(100)
// Open the dialog
.get("#trigger")
.click()
.wait(500)
// Verify dialog is open
.get("#dialog")
.should("exist")
.get("#overlay")
.should("exist")
// Close the dialog
.get("#close")
.click()
// Wait for exit animation to complete
.wait(600)
// Verify exit animation completed (onExitComplete was called)
.get("#exit-complete")
.should("have.attr", "data-value", "true")
// Verify the dialog elements are removed from DOM after exit
.get("#dialog")
.should("not.exist")
.get("#overlay")
.should("not.exist")
})

it("Exit animation actually runs (not immediately removed)", () => {
cy.visit("?test=animate-presence-radix-dialog")
.wait(100)
// Open the dialog
.get("#trigger")
.click()
.wait(500)
// Verify dialog is open
.get("#overlay")
.should("exist")
// Close the dialog
.get("#close")
.click()
// Check immediately - overlay should still exist during exit animation
.get("#overlay")
.should("exist")
// Wait a small amount for animation to start but not complete
.wait(100)
// Overlay should still be animating out
.get("#overlay")
.should("exist")
// Now wait for full animation
.wait(400)
// Now it should be gone
.get("#overlay")
.should("not.exist")
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
describe("Motion ref forwarding", () => {
it("RefObject receives the DOM element", () => {
cy.visit("?test=motion-ref-forwarding")
.wait(100)
.get("#ref-object-target")
.should("exist")
.get("#check-ref")
.click()
.wait(50)
.get("#ref-object-value")
.should("have.attr", "data-value", "DIV")
})

it("Callback ref is called with element on mount", () => {
cy.visit("?test=motion-ref-forwarding")
.wait(100)
.get("#callback-mount-called")
.should("have.attr", "data-value", "true")
.get("#callback-mount-value")
.should("have.attr", "data-value", "DIV")
})

it("Callback ref cleanup is handled on unmount", () => {
cy.visit("?test=motion-ref-forwarding")
.wait(100)
// Unmount the components
.get("#toggle")
.click()
.wait(100)
// Either cleanup function is called (React 19 pattern)
// OR callback ref is called with null (React 18 pattern)
.then(() => {
cy.get("#callback-unmount-called").then(($unmount) => {
cy.get("#cleanup-called").then(($cleanup) => {
const unmountCalled =
$unmount.attr("data-value") === "true"
const cleanupCalled =
$cleanup.attr("data-value") === "true"
expect(
unmountCalled || cleanupCalled,
"Either unmount or cleanup should be called"
).to.be.true
})
})
})
})
})
1 change: 1 addition & 0 deletions packages/framer-motion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"tslib": "^2.4.0"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@thednp/dommatrix": "^2.0.11",
"@types/three": "0.137.0",
"three": "0.137.0"
Expand Down
Loading
Loading