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
113 changes: 113 additions & 0 deletions dev/react/src/tests/animate-presence-pop-ref.tsx
Original file line number Diff line number Diff line change
@@ -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<BoxHandle, { id: string; style?: React.CSSProperties }>(
function Box({ id, style }, ref) {
const elementRef = useRef<HTMLDivElement>(null)

useImperativeHandle(ref, () => ({
flash: () => {
if (elementRef.current) {
elementRef.current.style.opacity = "0.5"
setTimeout(() => {
if (elementRef.current) {
elementRef.current.style.opacity = "1"
}
}, 100)
}
},
}))

return (
<motion.div
ref={elementRef}
id={id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.001 } }}
exit={{ opacity: 0, transition: { duration: 10 } }}
style={{ ...boxStyles, ...style }}
/>
)
}
)

/**
* Another test: Component with direct ref forwarding to motion element
*/
const BoxWithDirectRef = forwardRef<HTMLDivElement, { id: string; style?: React.CSSProperties }>(
function BoxWithDirectRef({ id, style }, ref) {
return (
<motion.div
ref={ref}
id={id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.001 } }}
exit={{ opacity: 0, transition: { duration: 10 } }}
style={{ ...boxStyles, ...style }}
/>
)
}
)

export const App = () => {
const [state, setState] = useState(true)
const params = new URLSearchParams(window.location.search)
const testType = params.get("type") || "imperative"

const boxRef = useRef<BoxHandle>(null)
const directRef = useRef<HTMLDivElement>(null)

return (
<div style={containerStyles} onClick={() => setState(!state)}>
<AnimatePresence mode="popLayout">
<motion.div
key="static"
id="static"
layout
style={{ ...boxStyles, backgroundColor: "gray" }}
/>
{state ? (
testType === "imperative" ? (
<Box
key="box"
id="box"
ref={boxRef}
style={{ backgroundColor: "green" }}
/>
) : (
<BoxWithDirectRef
key="box"
id="box"
ref={directRef}
style={{ backgroundColor: "blue" }}
/>
)
) : null}
</AnimatePresence>
<div id="result" data-state={state ? "shown" : "hidden"} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement> })?.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<HTMLElement> })?.ref ??
(children as unknown as { ref?: React.Ref<HTMLElement> })?.ref
const composedRef = useComposedRefs(ref, childRef)

/**
* We create and inject a style block so we can apply this explicit
Expand Down