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.13.2] 2024-12-04

### 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 `parentDOM` prop.

## [11.13.1] 2024-12-03

### Fixed
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
parentDom?: 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, parentDom }: 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 = parentDom ?? 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"
parentDom?: HTMLElement | ShadowRoot
}

export const PresenceChild = ({
Expand All @@ -28,6 +29,7 @@ export const PresenceChild = ({
custom,
presenceAffectsLayout,
mode,
parentDom
}: 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} parentDom={parentDom}>{children}</PopChild>
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,39 @@ describe("AnimatePresence", () => {
return await expect(promise).resolves.toBe(3)
})

test("Can cycle through multiple components with mode === 'popLayout' and dom", async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% on what this test is trying to achieve.

Is it more expected based on the changes that we should check that style blocks are being correctly added to the testDom elements?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was aiming for that originally but there doesn't seem to be a way to validate the style block is there, so I resorted to having something that at least exercised the prop. But now that I've added the cypress tests per the other comment, that covers it more thoroughly so I'll just remove this. Let me know if you think there should still be something here

const promise = new Promise<number>((resolve) => {
const Component = ({ i }: { i: number }) => {
const testDom = document.createElement('div')
document.body.appendChild(testDom)
return (
<AnimatePresence mode="popLayout" parentDom={testDom}>
<motion.div
key={i}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
/>
</AnimatePresence>
)
}

const { container, rerender } = render(<Component i={0} />)
rerender(<Component i={0} />)
setTimeout(() => {
rerender(<Component i={1} />)
rerender(<Component i={1} />)
}, 50)
setTimeout(() => {
rerender(<Component i={2} />)
rerender(<Component i={2} />)
resolve(container.childElementCount)
}, 400)
})

return await expect(promise).resolves.toBe(3)
})

test("Only renders one child at a time if mode === 'wait'", async () => {
const promise = new Promise<number>((resolve) => {
const Component = ({ i }: { i: number }) => {
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",
parentDom,
}) => {
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}
parentDom={parentDom}
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"

/**
* Parent DOM element used when injecting styles, used when mode === `"popLayout"`.
* This defaults to document.head but can be overridden e.g. for use in shadow DOM.
*/
parentDom?: 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