diff --git a/CHANGELOG.md b/CHANGELOG.md index 252a5fda7a..cd0ce2ff12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dev/react/src/tests/animate-presence-pop-shadow-root.tsx b/dev/react/src/tests/animate-presence-pop-shadow-root.tsx new file mode 100644 index 0000000000..32f96e480d --- /dev/null +++ b/dev/react/src/tests/animate-presence-pop-shadow-root.tsx @@ -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(null) + + useEffect(() => { + if (!ref.current) return + + animate(ref.current, { opacity: [0, 1] }, { duration: 1 }) + animate(ref.current, { opacity: [1, 0.5] }, { duration: 1 }) + }, []) + + return ( +
setState(!state)} + > + + 1 }} + style={{ ...itemStyle }} + /> + {state ? ( + + ) : null} + 1 }} + style={{ ...itemStyle, backgroundColor: "blue" }} + /> + +
+
+ ) +} + +export const App = () => { + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + + const shadowRoot = + ref.current.shadowRoot ?? ref.current.attachShadow({ mode: "open" }) + const root = createRoot(shadowRoot) + root.render() + return () => { + root.unmount(); + } + }, []) + + return
+} diff --git a/packages/framer-motion/cypress.json b/packages/framer-motion/cypress.json index 2831353e47..7f685ba98c 100644 --- a/packages/framer-motion/cypress.json +++ b/packages/framer-motion/cypress.json @@ -1,5 +1,6 @@ { "baseUrl": "http://localhost:9990", + "experimentalShadowDomSupport": true, "video": true, "screenshots": false, "retries": 2, diff --git a/packages/framer-motion/cypress/integration/animate-presence-pop-shadow-root.ts b/packages/framer-motion/cypress/integration/animate-presence-pop-shadow-root.ts new file mode 100644 index 0000000000..c8e8730fe7 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-pop-shadow-root.ts @@ -0,0 +1,142 @@ +interface BoundingBox { + top: number + left: number + width: number + height: number +} + +function expectBbox(element: HTMLElement, expectedBbox: Partial) { + 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, + }) + }) + }) +}) diff --git a/packages/framer-motion/cypress/integration/animate-style.ts b/packages/framer-motion/cypress/integration/animate-style.ts index 0cfb1bc903..d9f5d6ecb0 100644 --- a/packages/framer-motion/cypress/integration/animate-style.ts +++ b/packages/framer-motion/cypress/integration/animate-style.ts @@ -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) diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index 17cf8867a4..500d4c0e4b 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -15,6 +15,7 @@ interface Size { interface Props { children: React.ReactElement isPresent: boolean + root?: HTMLElement | ShadowRoot } interface MeasureProps extends Props { @@ -50,7 +51,7 @@ class PopChildMeasure extends React.Component { } } -export function PopChild({ children, isPresent }: Props) { +export function PopChild({ children, isPresent, root }: Props) { const id = useId() const ref = useRef(null) const size = useRef({ @@ -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}"] { @@ -92,7 +94,7 @@ export function PopChild({ children, isPresent }: Props) { } return () => { - document.head.removeChild(style) + parent.removeChild(style) } }, [isPresent]) diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index b9fe4b6e5d..4539a0d8bc 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -18,6 +18,7 @@ interface PresenceChildProps { custom?: any presenceAffectsLayout: boolean mode: "sync" | "popLayout" | "wait" + root?: HTMLElement | ShadowRoot } export const PresenceChild = ({ @@ -28,6 +29,7 @@ export const PresenceChild = ({ custom, presenceAffectsLayout, mode, + root }: PresenceChildProps) => { const presenceChildren = useConstant(newChildrenMap) const id = useId() @@ -83,7 +85,7 @@ export const PresenceChild = ({ }, [isPresent]) if (mode === "popLayout") { - children = {children} + children = {children} } return ( diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index 685337b525..17c5671e67 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -817,7 +817,7 @@ describe("AnimatePresence with custom components", () => { setTimeout(() => { resolve(container.childElementCount) - }, 500) + }, 750) }) return await expect(promise).resolves.toBe(3) diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index 8131f2c4c6..fc0d286291 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -53,6 +53,7 @@ export const AnimatePresence: React.FunctionComponent< onExitComplete, presenceAffectsLayout = true, mode = "sync", + root, }) => { invariant(!exitBeforeEnter, "Replace exitBeforeEnter with mode='wait'") @@ -207,6 +208,7 @@ export const AnimatePresence: React.FunctionComponent< custom={isPresent ? undefined : custom} presenceAffectsLayout={presenceAffectsLayout} mode={mode} + root={root} onExitComplete={isPresent ? undefined : onExit} > {child} diff --git a/packages/framer-motion/src/components/AnimatePresence/types.ts b/packages/framer-motion/src/components/AnimatePresence/types.ts index 5f54c02aa1..e6e608dbe6 100644 --- a/packages/framer-motion/src/components/AnimatePresence/types.ts +++ b/packages/framer-motion/src/components/AnimatePresence/types.ts @@ -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. diff --git a/packages/framer-motion/src/motion/__tests__/variant.test.tsx b/packages/framer-motion/src/motion/__tests__/variant.test.tsx index bfbab98dfc..8a73a28f42 100644 --- a/packages/framer-motion/src/motion/__tests__/variant.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/variant.test.tsx @@ -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) }) })