diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cc93428a..6451d00c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.43.0] 2026-07-27 + +### Added + +- Hardware acceleration for `backgroundColor` in supported browsers. + +### Fixed + +- `AnimatePresence`: Exiting children no longer interleave with entering children, which could reorder and remount children present in both renders. + ## [12.42.2] 2026-07-01 ### Fixed diff --git a/dev/react/src/tests/animate-presence-strict-dataset.tsx b/dev/react/src/tests/animate-presence-strict-dataset.tsx new file mode 100644 index 0000000000..01541a1c6c --- /dev/null +++ b/dev/react/src/tests/animate-presence-strict-dataset.tsx @@ -0,0 +1,116 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useEffect, useRef, useState } from "react" + +/** + * Reproduction for #3746. + * + * A bar chart with two datasets that share a key ("persist"). Switching + * datasets should translate the persisting bar to its new position. It should + * never replay its enter animation (width from 0) and never re-mount. + * + * The reported bug appears on the *second* switch, under React.StrictMode + * (dev/react already renders every test inside StrictMode). + */ + +interface Datum { + id: string + value: number + color: string +} + +const datasetA: Datum[] = [ + { id: "a", value: 120, color: "blue" }, + { id: "persist", value: 200, color: "red" }, + { id: "b", value: 160, color: "green" }, +] + +const datasetB: Datum[] = [ + { id: "c", value: 140, color: "purple" }, + { id: "d", value: 180, color: "orange" }, + { id: "persist", value: 240, color: "red" }, +] + +interface PresenceDatasetState { + mounts: Record + resetWidths: () => void + minWidth: () => number +} + +declare global { + interface Window { + presenceDataset: PresenceDatasetState + } +} + +/** + * Sample the persisting bar every frame so the spec can assert it never + * collapsed towards 0, rather than catching it at a single lucky moment. + */ +const widths: number[] = [] + +const state: PresenceDatasetState = { + mounts: {}, + resetWidths: () => { + widths.length = 0 + }, + minWidth: () => (widths.length ? Math.min(...widths) : -1), +} + +window.presenceDataset = state + +function Bar({ id, value, color }: Datum) { + useEffect(() => { + state.mounts[id] = (state.mounts[id] || 0) + 1 + }, []) + + return ( + + ) +} + +export function App() { + const [data, setData] = useState(datasetA) + const raf = useRef(0) + + useEffect(() => { + const sample = () => { + const el = document.getElementById("bar-persist") + if (el) widths.push(el.getBoundingClientRect().width) + raf.current = requestAnimationFrame(sample) + } + raf.current = requestAnimationFrame(sample) + return () => cancelAnimationFrame(raf.current) + }, []) + + return ( +
+ +
+ + {data.map((datum) => ( + + ))} + +
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/animate-presence-strict-dataset.ts b/packages/framer-motion/cypress/integration/animate-presence-strict-dataset.ts new file mode 100644 index 0000000000..3754fc82fc --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-strict-dataset.ts @@ -0,0 +1,33 @@ +/** + * Reproduction for #3746 — a key present in both datasets must not be treated + * as freshly entering when the dataset is switched. + * + * The reported symptom is that on the *second* switch the persisting bar + * "disappears and reappears, and its width animates from 0". We sample its + * width every frame for the whole switch, so a collapse can't be missed + * between assertions. + */ +describe("AnimatePresence dataset switch", () => { + it("Never replays the enter animation for a persisting key", () => { + cy.visit("?test=animate-presence-strict-dataset") + .wait(700) + // First switch A -> B, let it settle completely. + .get("#switch") + .trigger("click", 5, 5, { force: true }) + .wait(700) + // Start sampling fresh, then perform the second switch. + .window() + .then((win: any) => win.presenceDataset.resetWidths()) + .get("#switch") + .trigger("click", 5, 5, { force: true }) + .wait(700) + .window() + .then((win: any) => { + // The bar goes from 240 -> 200. It must never dip towards 0. + expect(win.presenceDataset.minWidth()).to.be.greaterThan(150) + // And it must never have re-mounted. StrictMode double-invokes + // effects on mount, so a bar that mounts once reports 2. + expect(win.presenceDataset.mounts.persist).to.equal(2) + }) + }) +}) 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 2cf43ad2da..078f9aeaea 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -770,6 +770,58 @@ describe("AnimatePresence", () => { expect(opacity.get()).toBe(1) }) + + test("Exiting children don't reorder present children (#3746)", async () => { + const Component = ({ ids }: { ids: string[] }) => ( + + {ids.map((id) => ( + + ))} + + ) + + const { container, rerender } = render( + + ) + + await act(async () => { + await nextFrame() + }) + + const persist = container.querySelector('[data-id="persist"]') + + // "a" and "b" exit, "c" and "d" enter, "persist" stays. + await act(async () => { + rerender() + }) + await act(async () => { + await nextFrame() + }) + + const order = Array.from(container.querySelectorAll("[data-id]")).map( + (el) => el.getAttribute("data-id") + ) + + /** + * Each exiting child should hold its place relative to the children it + * sat between: "a" led the list, "b" followed "persist". Exiting + * children used to be spliced in at their index within the *previously* + * rendered children, which indexes into the wrong list and interleaved + * them with the entering children — ["a", "c", "b", "d", "persist"]. + */ + expect(order.indexOf("a")).toBe(0) + expect(order.indexOf("b")).toBeGreaterThan(order.indexOf("persist")) + + // And the persisting child must be the same element, never remounted. + expect(container.querySelector('[data-id="persist"]')).toBe(persist) + }) }) describe("AnimatePresence with custom components", () => { diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index b1e1df603f..fc0f7c4adb 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -128,14 +128,23 @@ export const AnimatePresence = ({ /** * Loop through all the currently rendered components and decide which * are exiting. + * + * Exiting children are reinserted directly after the child they + * previously followed. Splicing at their index within the previously + * rendered children, as we used to, indexes into the wrong list: it + * can interleave them with entering children and push present children + * to new positions, remounting them (#3746). */ - for (let i = 0; i < renderedChildren.length; i++) { - const child = renderedChildren[i] - const key = getChildKey(child) + let insertionIndex = 0 - if (!presentKeys.includes(key)) { - nextChildren.splice(i, 0, child) + for (const child of renderedChildren) { + const presentIndex = presentKeys.indexOf(getChildKey(child)) + + if (presentIndex === -1) { + nextChildren.splice(insertionIndex++, 0, child) exitingChildren.push(child) + } else { + insertionIndex = presentIndex + exitingChildren.length + 1 } }