Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions dev/react/src/tests/animate-presence-strict-dataset.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>
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 (
<motion.div
layout
id={`bar-${id}`}
initial={{ width: 0 }}
animate={{ width: value }}
exit={{ width: 0 }}
transition={{ duration: 0.4, ease: "linear" }}
style={{
height: 40,
marginBottom: 8,
background: color,
}}
/>
)
}

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 (
<div>
<button
id="switch"
onClick={() =>
setData((d) => (d === datasetA ? datasetB : datasetA))
}
>
Switch dataset
</button>
<div style={{ position: "relative" }}>
<AnimatePresence>
{data.map((datum) => (
<Bar key={datum.id} {...datum} />
))}
</AnimatePresence>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }) => (
<AnimatePresence>
{ids.map((id) => (
<motion.div
key={id}
data-id={id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 10 }}
/>
))}
</AnimatePresence>
)

const { container, rerender } = render(
<Component ids={["a", "persist", "b"]} />
)

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(<Component ids={["c", "d", "persist"]} />)
})
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", () => {
Expand Down
19 changes: 14 additions & 5 deletions packages/framer-motion/src/components/AnimatePresence/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down