diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 3dacaf5894..9843edfc6c 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -1,5 +1,6 @@ import * as React from "react" import { useId, useMemo } from "react" +import { ComponentKey } from "." import { PresenceContext, PresenceContextProps, @@ -11,11 +12,12 @@ import { PopChild } from "./PopChild" interface PresenceChildProps { children: React.ReactElement isPresent: boolean - onExitComplete?: () => void + onExitComplete?: (key: ComponentKey) => void initial?: false | VariantLabels custom?: any presenceAffectsLayout: boolean mode: "sync" | "popLayout" | "wait" + childKey: ComponentKey } export const PresenceChild = ({ @@ -26,6 +28,7 @@ export const PresenceChild = ({ custom, presenceAffectsLayout, mode, + childKey, }: PresenceChildProps) => { const presenceChildren = useConstant(newChildrenMap) const id = useId() @@ -43,7 +46,7 @@ export const PresenceChild = ({ if (!isComplete) return // can stop searching when any is incomplete } - onExitComplete?.() + onExitComplete?.(childKey) }, register: (childId: string) => { presenceChildren.set(childId, false) @@ -67,7 +70,7 @@ export const PresenceChild = ({ * component immediately. */ React.useEffect(() => { - !isPresent && !presenceChildren.size && onExitComplete?.() + !isPresent && !presenceChildren.size && onExitComplete?.(childKey) }, [isPresent]) if (mode === "popLayout") { diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index d652df695c..00d22a0beb 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -1,38 +1,39 @@ import { useRef, + useCallback, isValidElement, cloneElement, Children, ReactElement, ReactNode, useContext, -} from "react" -import * as React from "react" -import { env } from "../../utils/process" -import { AnimatePresenceProps } from "./types" -import { useForceUpdate } from "../../utils/use-force-update" -import { useIsMounted } from "../../utils/use-is-mounted" -import { PresenceChild } from "./PresenceChild" -import { LayoutGroupContext } from "../../context/LayoutGroupContext" -import { useIsomorphicLayoutEffect } from "../../utils/use-isomorphic-effect" -import { useUnmountEffect } from "../../utils/use-unmount-effect" -import { warnOnce } from "../../utils/warn-once" - -type ComponentKey = string | number - -const getChildKey = (child: ReactElement): ComponentKey => child.key || "" - -function updateChildLookup( + } from "react" + import * as React from "react" + import { env } from "../../utils/process" + import { AnimatePresenceProps } from "./types" + import { useForceUpdate } from "../../utils/use-force-update" + import { useIsMounted } from "../../utils/use-is-mounted" + import { PresenceChild } from "./PresenceChild" + import { LayoutGroupContext } from "../../context/LayoutGroupContext" + import { useIsomorphicLayoutEffect } from "../../utils/use-isomorphic-effect" + import { useUnmountEffect } from "../../utils/use-unmount-effect" + import { warnOnce } from "../../utils/warn-once" + + export type ComponentKey = string | number + + const getChildKey = (child: ReactElement): ComponentKey => child.key || "" + + function updateChildLookup( children: ReactElement[], allChildren: Map> -) { + ) { children.forEach((child) => { const key = getChildKey(child) allChildren.set(key, child) }) -} + } -function onlyElements(children: ReactNode): ReactElement[] { + function onlyElements(children: ReactNode): ReactElement[] { const filtered: ReactElement[] = [] // We use forEach here instead of map as map mutates the component key by preprending `.$` @@ -41,44 +42,70 @@ function onlyElements(children: ReactNode): ReactElement[] { }) return filtered -} - -/** - * `AnimatePresence` enables the animation of components that have been removed from the tree. - * - * When adding/removing more than a single child, every child **must** be given a unique `key` prop. - * - * Any `motion` components that have an `exit` property defined will animate out when removed from - * the tree. - * - * ```jsx - * import { motion, AnimatePresence } from 'framer-motion' - * - * export const Items = ({ items }) => ( - * - * {items.map(item => ( - * - * ))} - * - * ) - * ``` - * - * You can sequence exit animations throughout a tree using variants. - * - * If a child contains multiple `motion` components with `exit` props, it will only unmount the child - * once all `motion` components have finished animating out. Likewise, any components using - * `usePresence` all need to call `safeToRemove`. - * - * @public - */ -export const AnimatePresence: React.FunctionComponent< + } + + function splitChildrenByKeys( + keys: ComponentKey[], + children: ReactElement[], + mapFunction?: (child: ReactElement) => ReactElement + ): ReactElement[][] { + const chunks: ReactElement[][] = [] + let insertionStartIndex = 0 + + keys.forEach((key) => { + const insertionEndIndex = children.findIndex( + (child) => getChildKey(child) === key + ) + + let chunk = children.slice(insertionStartIndex, insertionEndIndex) + if (mapFunction) chunk = chunk.map(mapFunction) + chunks.push(chunk) + insertionStartIndex = insertionEndIndex + 1 + }) + + let chunk = children.slice(insertionStartIndex, children.length) + if (mapFunction) chunk = chunk.map(mapFunction) + chunks.push(chunk) + + return chunks + } + + /** + * `AnimatePresence` enables the animation of components that have been removed from the tree. + * + * When adding/removing more than a single child, every child **must** be given a unique `key` prop. + * + * Any `motion` components that have an `exit` property defined will animate out when removed from + * the tree. + * + * ```jsx + * import { motion, AnimatePresence } from 'framer-motion' + * + * export const Items = ({ items }) => ( + * + * {items.map(item => ( + * + * ))} + * + * ) + * ``` + * + * You can sequence exit animations throughout a tree using variants. + * + * If a child contains multiple `motion` components with `exit` props, it will only unmount the child + * once all `motion` components have finished animating out. Likewise, any components using + * `usePresence` all need to call `safeToRemove`. + * + * @public + */ + export const AnimatePresence: React.FunctionComponent< React.PropsWithChildren -> = ({ + > = ({ children, custom, initial = true, @@ -86,7 +113,7 @@ export const AnimatePresence: React.FunctionComponent< exitBeforeEnter, presenceAffectsLayout = true, mode = "sync", -}) => { + }) => { // Support deprecated exitBeforeEnter prop if (exitBeforeEnter) { mode = "wait" @@ -105,7 +132,7 @@ export const AnimatePresence: React.FunctionComponent< const filteredChildren = onlyElements(children) let childrenToRender = filteredChildren - const exiting = new Set() + const exiting = useRef(new Set()).current // Keep a living record of the children we're actually rendering so we // can diff to figure out which are entering and exiting @@ -120,6 +147,37 @@ export const AnimatePresence: React.FunctionComponent< // we play onMount animations or not. const isInitialRender = useRef(true) + const onPresenceChildRemove = useCallback( + (key: ComponentKey) => { + allChildren.delete(key) + exiting.delete(key) + + // Remove this child from the present children + const removeIndex = presentChildren.current.findIndex( + (presentChild) => presentChild.key === key + ) + presentChildren.current.splice(removeIndex, 1) + + // Defer re-rendering until all exiting children have indeed left + if (!exiting.size) { + presentChildren.current = filteredChildren + + if (isMounted.current === false) return + + forceRender() + onExitComplete && onExitComplete() + } + }, + [ + allChildren, + exiting, + filteredChildren, + forceRender, + isMounted, + onExitComplete, + ] + ) + useIsomorphicLayoutEffect(() => { isInitialRender.current = false @@ -139,6 +197,7 @@ export const AnimatePresence: React.FunctionComponent< {childrenToRender.map((child) => ( { - // If this component is actually entering again, early return - if (targetKeys.indexOf(key) !== -1) return - - const child = allChildren.get(key) - if (!child) return - - const insertionIndex = presentKeys.indexOf(key) - - const onExit = () => { - allChildren.delete(key) - exiting.delete(key) - - // Remove this child from the present children - const removeIndex = presentChildren.current.findIndex( - (presentChild) => presentChild.key === key + // split the presentChildren based on the key of the component you are preserving + const presentChunks = splitChildrenByKeys( + preservingKeys, + presentChildren.current, + (_child) => { + const key = getChildKey(_child) + const child = allChildren.get(key)! + + const extingChild = ( + + {child} + ) - presentChildren.current.splice(removeIndex, 1) - - // Defer re-rendering until all exiting children have indeed left - if (!exiting.size) { - presentChildren.current = filteredChildren - - if (isMounted.current === false) return - - forceRender() - onExitComplete && onExitComplete() - } + return extingChild } + ) - childrenToRender.splice( - insertionIndex, - 0, - - {child} - - ) - }) - - // Add `MotionContext` even to children that don't need it to ensure we're rendering - // the same tree between renders - childrenToRender = childrenToRender.map((child) => { - const key = child.key as string | number - return exiting.has(key) ? ( - child - ) : ( + const targetChunks = splitChildrenByKeys( + preservingKeys, + filteredChildren, + (child) => ( + // Add `MotionContext` even to children that don't need it to ensure we're rendering + // the same tree between renders ) + ) + + // Combine the chunk separated by the preservingKeys. + // + // If a change occurs in the rendering array, + // insert the chunk where the change occurred in the previous location. + // + // presentChildren -> children + // [A] [1] + // [D] [A] + // [E] [2] + // [F] [B] + // [B] [3] + // [C] [C] + // + // init -> animate -> Exit Complete + // + // [1] [1] <--- targetChunk - 1 + // [A] [A] [A] <--- preservingKey + // [D] [D] + // [E] [E] <--- presentChunk - 1 + // [F] [F] + // [2] [2] <--- targetChunk - 2 + // [B] [B] [B] <--- preservingKey + // [3] [3] <--- targetChunk - 3 + // [C] [C] [C] <--- preservingKey + + childrenToRender = [] + Array.from({ length: preservingKeys.length + 1 }).forEach((_, i) => { + const key = preservingKeys[i] + const child = allChildren.get(key) + + childrenToRender = childrenToRender.concat(presentChunks[i]) + + // If we currently have exiting children, and we're deferring rendering incoming children + // until after all current children have exiting, empty the childrenToRender array + if (!(mode === "wait" && exiting.size)) { + childrenToRender = childrenToRender.concat(targetChunks[i]) + } + + if (child) { + childrenToRender.push( + + {child} + + ) + } }) if ( @@ -258,4 +344,4 @@ export const AnimatePresence: React.FunctionComponent< : childrenToRender.map((child) => cloneElement(child))} ) -} + }