From c84e7bd380df8be9ea7d4b054ba7c1ceebecb2ad Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 27 Jul 2026 11:06:05 +0200 Subject: [PATCH 1/2] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cc93428a..39a1acb8d2 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. +## [12.43.0] 2026-07-27 + +### Added + +- Hardware acceleration for `backgroundColor` in supported browsers. + ## [12.42.2] 2026-07-01 ### Fixed From f5326751e5604c1ac855f7e14bb7cf4d07f01542 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 27 Jul 2026 12:19:00 +0200 Subject: [PATCH 2/2] Fix AnimatePresence reordering present children when others exit Exiting children were spliced into the new children array at their index within the previously rendered children. Those are two different index spaces, so exiting children could be interleaved with entering children and push present children to new positions, remounting them (#3746). Reinsert each exiting child directly after the child it previously followed instead. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + .../tests/animate-presence-strict-dataset.tsx | 116 ++++++++++++++++++ .../animate-presence-strict-dataset.ts | 33 +++++ .../__tests__/AnimatePresence.test.tsx | 52 ++++++++ .../src/components/AnimatePresence/index.tsx | 19 ++- 5 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 dev/react/src/tests/animate-presence-strict-dataset.tsx create mode 100644 packages/framer-motion/cypress/integration/animate-presence-strict-dataset.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a1acb8d2..6451d00c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ Undocumented APIs should be considered internal and may change without warning. - 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 } }