Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for AnimatePresence when using shadow DOM #2830

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/).

Undocumented APIs should be considered internal and may change without warning.

## [11.16.4] 2025-01-09

### Fixed

- Allowing custom DOM element for injecting styles when using AnimatePresence with mode === `popLayout`. Fixes shadow DOM issue [#2508](https://github.com/framer/motion/issues/2508) by passing the shadow root into the new `root` prop.

## [11.16.3] 2024-01-09

### Fixed
Expand Down
93 changes: 93 additions & 0 deletions dev/react/src/tests/animate-presence-pop-shadow-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AnimatePresence, motion, animate } from "framer-motion"
import { useState, useRef, useEffect } from "react"
import { createRoot } from "react-dom/client"

const boxStyle = {
width: "100px",
height: "100px",
backgroundColor: "red",
}


const AppContent = ({ root }: { root: ShadowRoot }) => {
const [state, setState] = useState(true)
const params = new URLSearchParams(window.location.search)
const position = params.get("position") || ("static" as any)
const itemStyle =
position === "relative"
? { ...boxStyle, position, top: 100, left: 100 }
: boxStyle

const ref = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!ref.current) return

animate(ref.current, { opacity: [0, 1] }, { duration: 1 })
animate(ref.current, { opacity: [1, 0.5] }, { duration: 1 })
}, [])

return (
<section
style={{
position: "relative",
display: "flex",
flexDirection: "column",
padding: "100px",
}}
onClick={() => setState(!state)}
>
<AnimatePresence mode="popLayout" root={root}>
<motion.div
key="a"
id="a"
layout
transition={{ ease: () => 1 }}
style={{ ...itemStyle }}
/>
{state ? (
<motion.div
key="b"
id="b"
animate={{
opacity: 1,
transition: { duration: 0.001 },
}}
exit={{ opacity: 0, transition: { duration: 10 } }}
layout
style={{ ...itemStyle, backgroundColor: "green" }}
/>
) : null}
<motion.div
key="c"
id="c"
layout
transition={{ ease: () => 1 }}
style={{ ...itemStyle, backgroundColor: "blue" }}
/>
</AnimatePresence>
<div
ref={ref}
style={{ ...itemStyle, backgroundColor: "purple" }}
/>
</section>
)
}

export const App = () => {
const ref = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!ref.current) return

const shadowRoot =
ref.current.shadowRoot ?? ref.current.attachShadow({ mode: "open" })
const root = createRoot(shadowRoot)
root.render(<AppContent root={shadowRoot} />)
return () => {
root.unmount();
}
}, [])

return <div id="shadow" ref={ref}></div>
}
1 change: 1 addition & 0 deletions packages/framer-motion/cypress.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"baseUrl": "http://localhost:9990",
"experimentalShadowDomSupport": true,
"video": true,
"screenshots": false,
"retries": 2,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
interface BoundingBox {
top: number
left: number
width: number
height: number
}

function expectBbox(element: HTMLElement, expectedBbox: Partial<BoundingBox>) {
const bbox = element.getBoundingClientRect()
expect(bbox.left).to.equal(expectedBbox.left)
expect(bbox.top).to.equal(expectedBbox.top)
expectedBbox.width && expect(bbox.width).to.equal(expectedBbox.width)
expectedBbox.height && expect(bbox.height).to.equal(expectedBbox.height)
}

describe("AnimatePresence popLayout with shadowRoot", () => {
it("correctly pops exiting elements out of the DOM", () => {
cy.visit("?test=animate-presence-pop-shadow-root")
.wait(50)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 200,
left: 100,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 100,
width: 100,
height: 100,
})
})
.trigger("click", 60, 60, { force: true })
.wait(100)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 200,
left: 100,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 200,
left: 100,
width: 100,
height: 100,
})
})
.trigger("click", 60, 60, { force: true })
.wait(100)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 200,
left: 100,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 100,
width: 100,
height: 100,
})
})
})

it("correctly pops exiting elements out of the DOM when they already have an explicit top/left", () => {
cy.visit("?test=animate-presence-pop-shadow-root&position=relative")
.wait(50)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 200,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 400,
left: 200,
width: 100,
height: 100,
})
})
.trigger("click", 60, 60, { force: true })
.wait(100)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 200,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 200,
width: 100,
height: 100,
})
})
.trigger("click", 60, 60, { force: true })
.wait(100)
.get("#b", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 300,
left: 200,
width: 100,
height: 100,
})
})
.get("#c", { includeShadowDom: true })
.should(([$a]: any) => {
expectBbox($a, {
top: 400,
left: 200,
width: 100,
height: 100,
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("animateMini()", () => {

it("pause() correctly pauses the animation", () => {
cy.visit("?test=animate-style-pause")
.wait(200)
.wait(400)
.get("#box")
.should(([$element]: any) => {
expect($element.getBoundingClientRect().width).not.to.equal(100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface Size {
interface Props {
children: React.ReactElement
isPresent: boolean
root?: HTMLElement | ShadowRoot
}

interface MeasureProps extends Props {
Expand Down Expand Up @@ -50,7 +51,7 @@ class PopChildMeasure extends React.Component<MeasureProps> {
}
}

export function PopChild({ children, isPresent }: Props) {
export function PopChild({ children, isPresent, root }: Props) {
const id = useId()
const ref = useRef<HTMLElement>(null)
const size = useRef<Size>({
Expand Down Expand Up @@ -78,7 +79,8 @@ export function PopChild({ children, isPresent }: Props) {

const style = document.createElement("style")
if (nonce) style.nonce = nonce
document.head.appendChild(style)
const parent = root ?? document.head;
parent.appendChild(style)
if (style.sheet) {
style.sheet.insertRule(`
[data-motion-pop-id="${id}"] {
Expand All @@ -92,7 +94,7 @@ export function PopChild({ children, isPresent }: Props) {
}

return () => {
document.head.removeChild(style)
parent.removeChild(style)
}
}, [isPresent])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface PresenceChildProps {
custom?: any
presenceAffectsLayout: boolean
mode: "sync" | "popLayout" | "wait"
root?: HTMLElement | ShadowRoot
}

export const PresenceChild = ({
Expand All @@ -28,6 +29,7 @@ export const PresenceChild = ({
custom,
presenceAffectsLayout,
mode,
root
}: PresenceChildProps) => {
const presenceChildren = useConstant(newChildrenMap)
const id = useId()
Expand Down Expand Up @@ -83,7 +85,7 @@ export const PresenceChild = ({
}, [isPresent])

if (mode === "popLayout") {
children = <PopChild isPresent={isPresent}>{children}</PopChild>
children = <PopChild isPresent={isPresent} root={root}>{children}</PopChild>
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ describe("AnimatePresence with custom components", () => {

setTimeout(() => {
resolve(container.childElementCount)
}, 500)
}, 750)
})

return await expect(promise).resolves.toBe(3)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const AnimatePresence: React.FunctionComponent<
onExitComplete,
presenceAffectsLayout = true,
mode = "sync",
root,
}) => {
invariant(!exitBeforeEnter, "Replace exitBeforeEnter with mode='wait'")

Expand Down Expand Up @@ -207,6 +208,7 @@ export const AnimatePresence: React.FunctionComponent<
custom={isPresent ? undefined : custom}
presenceAffectsLayout={presenceAffectsLayout}
mode={mode}
root={root}
onExitComplete={isPresent ? undefined : onExit}
>
{child}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export interface AnimatePresenceProps {
*/
mode?: "sync" | "popLayout" | "wait"

/**
* Root element to use when injecting styles, used when mode === `"popLayout"`.
* This defaults to document.head but can be overridden e.g. for use in shadow DOM.
*/
root?: HTMLElement | ShadowRoot;

/**
* Internal. Used in Framer to flag that sibling children *shouldn't* re-render as a result of a
* child being removed.
Expand Down
4 changes: 2 additions & 2 deletions packages/framer-motion/src/motion/__tests__/variant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,14 +459,14 @@ describe("animate prop as variant", () => {
setTimeout(() => {
expect(parentOpacity.get()).toBe(1)
expect(childOpacity.get()).not.toBe(1)
}, 50)
}, 100)
}}
/>
)
setTimeout(() => {
expect(parentOpacity.get()).toBe(0)
expect(childOpacity.get()).not.toBe(0)
}, 50)
}, 200)
})
})

Expand Down