Skip to content

Commit ed64e5f

Browse files
authored
Merge pull request #3625 from motiondivision/worktree-fix-issue-3141
Fix AnimatePresence stuck when state changes too fast
2 parents 5fad98c + 10427ae commit ed64e5f

File tree

4 files changed

+292
-1
lines changed

4 files changed

+292
-1
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { AnimatePresence, motion } from "framer-motion"
2+
import { useEffect, useMemo, useState } from "react"
3+
4+
/**
5+
* Reproduction for #3141: AnimatePresence mode="wait" gets stuck
6+
* when state changes cause rapid key alternation.
7+
*
8+
* Exact reproduction from the issue: loading/loaded pattern where
9+
* useEffect immediately flips loading to false, plus mode state
10+
* changes that cause re-renders during exit.
11+
*/
12+
export const App = () => {
13+
const [selected, setSelected] = useState({
14+
key: 0,
15+
loading: true,
16+
})
17+
const [mode1, setMode1] = useState(false)
18+
const [mode2, setMode2] = useState(false)
19+
const [mode3, setMode3] = useState(false)
20+
21+
useEffect(() => {
22+
if (selected.loading === true) {
23+
setSelected((prev) => ({ ...prev, loading: false }))
24+
}
25+
}, [selected])
26+
27+
useEffect(() => {
28+
if (selected.loading === false) {
29+
setMode1((prev) => !prev)
30+
setMode2((prev) => !prev)
31+
setMode3((prev) => !prev)
32+
}
33+
}, [selected])
34+
35+
useEffect(() => {
36+
// Rapidly cycle through keys on mount
37+
setSelected((prev) => ({ key: prev.key + 1, loading: true }))
38+
setSelected((prev) => ({ key: prev.key + 1, loading: true }))
39+
setSelected((prev) => ({ key: prev.key + 1, loading: true }))
40+
setSelected((prev) => ({ key: prev.key + 1, loading: true }))
41+
}, [])
42+
43+
const content = useMemo(() => {
44+
if (selected.loading === true) {
45+
const key = "loading-" + selected.key
46+
return {
47+
element: <div>loading</div>,
48+
key,
49+
}
50+
}
51+
52+
const key = "document-" + selected.key
53+
return {
54+
element: (
55+
<div style={{ display: "flex", flexDirection: "column" }}>
56+
loaded
57+
{"mode1" + mode1}
58+
{"mode2" + mode2}
59+
{"mode3" + mode3}
60+
</div>
61+
),
62+
key,
63+
}
64+
}, [selected, mode1, mode2, mode3])
65+
66+
const content2 = useMemo(() => {
67+
return (
68+
<AnimatePresence mode="wait">
69+
<motion.div
70+
key={content.key}
71+
id="content"
72+
initial={{ opacity: 0 }}
73+
animate={{ opacity: 1 }}
74+
exit={{ opacity: 0 }}
75+
transition={{ ease: [0.6, 0.6, 0, 1] }}
76+
>
77+
<span id="render-key">{"render: " + content.key}</span>
78+
{content.element}
79+
</motion.div>
80+
</AnimatePresence>
81+
)
82+
}, [content.element, content.key])
83+
84+
return (
85+
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
86+
<span id="current-key">{content.key}</span>
87+
<button
88+
id="change"
89+
onClick={() => {
90+
setSelected((prev) => ({
91+
key: prev.key + 1,
92+
loading: true,
93+
}))
94+
}}
95+
>
96+
Change
97+
</button>
98+
{content2}
99+
</div>
100+
)
101+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
describe("AnimatePresence: rapid key switching in mode='wait'", () => {
2+
it("Does not get stuck after rapid key changes on mount", () => {
3+
/**
4+
* #3141: Run multiple times since the bug is intermittent,
5+
* depending on exact timing of React batching and animations.
6+
*/
7+
for (let attempt = 0; attempt < 5; attempt++) {
8+
cy.visit("?test=animate-presence-rapid-switch")
9+
10+
// Wait for all state changes and animations to settle
11+
cy.wait(3000)
12+
13+
// The content should show the final "document-" key, not be
14+
// stuck on a "loading-" key from the rapid mount transitions
15+
cy.get("#content").should("exist")
16+
17+
cy.get("#render-key")
18+
.invoke("text")
19+
.should("match", /^render: document-/)
20+
21+
// The displayed key should match the current state
22+
cy.get("#current-key")
23+
.invoke("text")
24+
.then((currentKey) => {
25+
cy.get("#render-key")
26+
.invoke("text")
27+
.should("eq", "render: " + currentKey)
28+
})
29+
}
30+
})
31+
32+
it("Does not get stuck after rapid click changes", () => {
33+
cy.visit("?test=animate-presence-rapid-switch")
34+
35+
// Wait for initial mount to settle
36+
cy.wait(3000)
37+
38+
// Rapidly click to change keys multiple times
39+
for (let i = 0; i < 5; i++) {
40+
cy.get("#change").click()
41+
cy.wait(30)
42+
}
43+
44+
// Wait for animations to settle
45+
cy.wait(3000)
46+
47+
// Content should show the latest key, not be stuck
48+
cy.get("#current-key")
49+
.invoke("text")
50+
.then((currentKey) => {
51+
cy.get("#render-key")
52+
.invoke("text")
53+
.should("eq", "render: " + currentKey)
54+
})
55+
56+
// Element should be fully visible
57+
cy.get("#content").should(($el) => {
58+
const opacity = parseFloat(
59+
window.getComputedStyle($el[0]).opacity
60+
)
61+
expect(opacity).to.equal(1)
62+
})
63+
})
64+
})

packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { waitFor } from "@testing-library/dom"
22
import { motionValue, Variants } from "motion-dom"
3+
import * as React from "react"
34
import { act, createRef } from "react"
45
import {
56
AnimatePresence,
@@ -1500,4 +1501,129 @@ describe("AnimatePresence with custom components", () => {
15001501
// Without fix: no enter animation replayed (element stuck at exit position)
15011502
expect(enterCustomValues).toContain(1)
15021503
})
1504+
1505+
test("Does not get stuck when state changes cause rapid key alternation in mode='wait'", async () => {
1506+
/**
1507+
* Reproduction from #3141: A loading/loaded pattern where
1508+
* useEffect immediately flips loading to false, causing
1509+
* the key to change twice per selection (loading-N → document-N).
1510+
* On mount, multiple selections are batched, and the component
1511+
* gets stuck showing a stale child.
1512+
*/
1513+
const Component = () => {
1514+
const [selected, setSelected] = React.useState({
1515+
key: 0,
1516+
loading: true,
1517+
})
1518+
1519+
React.useEffect(() => {
1520+
if (selected.loading === true) {
1521+
setSelected((prev) => ({ ...prev, loading: false }))
1522+
}
1523+
}, [selected])
1524+
1525+
React.useEffect(() => {
1526+
// Rapidly cycle through keys on mount
1527+
setSelected((prev) => ({
1528+
key: prev.key + 1,
1529+
loading: true,
1530+
}))
1531+
setSelected((prev) => ({
1532+
key: prev.key + 1,
1533+
loading: true,
1534+
}))
1535+
setSelected((prev) => ({
1536+
key: prev.key + 1,
1537+
loading: true,
1538+
}))
1539+
setSelected((prev) => ({
1540+
key: prev.key + 1,
1541+
loading: true,
1542+
}))
1543+
}, [])
1544+
1545+
const contentKey = selected.loading
1546+
? "loading-" + selected.key
1547+
: "document-" + selected.key
1548+
1549+
return (
1550+
<AnimatePresence mode="wait">
1551+
<motion.div
1552+
key={contentKey}
1553+
data-testid="content"
1554+
initial={{ opacity: 0 }}
1555+
animate={{ opacity: 1 }}
1556+
exit={{ opacity: 0 }}
1557+
transition={{ duration: 0.1 }}
1558+
>
1559+
{contentKey}
1560+
</motion.div>
1561+
</AnimatePresence>
1562+
)
1563+
}
1564+
1565+
const { getByTestId } = render(<Component />)
1566+
1567+
// Wait for all state changes and exit animations to settle
1568+
await act(async () => {
1569+
await new Promise((resolve) => setTimeout(resolve, 1000))
1570+
})
1571+
await act(async () => {
1572+
await nextFrame()
1573+
await nextFrame()
1574+
})
1575+
1576+
// The final state should be document-4 (4 increments, loading=false)
1577+
const content = getByTestId("content")
1578+
expect(content.textContent).toContain("document-")
1579+
// Should NOT be stuck on a "loading-" key
1580+
expect(content.textContent).not.toContain("loading-")
1581+
})
1582+
1583+
test("Shows latest child after rapid key switches in mode='wait'", async () => {
1584+
/**
1585+
* Simplified reproduction: rapidly change keys in mode="wait"
1586+
* before exit animations complete. The last key should be
1587+
* visible after all animations settle.
1588+
*/
1589+
const Component = ({ i }: { i: number }) => {
1590+
return (
1591+
<AnimatePresence mode="wait">
1592+
<motion.div
1593+
key={i}
1594+
data-testid="content"
1595+
initial={{ opacity: 0 }}
1596+
animate={{ opacity: 1 }}
1597+
exit={{ opacity: 0 }}
1598+
transition={{ duration: 0.1 }}
1599+
>
1600+
{i}
1601+
</motion.div>
1602+
</AnimatePresence>
1603+
)
1604+
}
1605+
1606+
const { container, rerender, getByTestId } = render(
1607+
<Component i={0} />
1608+
)
1609+
rerender(<Component i={0} />)
1610+
1611+
// Rapidly switch keys without waiting for exit animations
1612+
rerender(<Component i={1} />)
1613+
rerender(<Component i={2} />)
1614+
rerender(<Component i={3} />)
1615+
1616+
// Wait for exit animations to complete
1617+
await act(async () => {
1618+
await new Promise((resolve) => setTimeout(resolve, 500))
1619+
})
1620+
await act(async () => {
1621+
await nextFrame()
1622+
await nextFrame()
1623+
})
1624+
1625+
// Only the last item should remain
1626+
expect(container.childElementCount).toBe(1)
1627+
expect(getByTestId("content").textContent).toBe("3")
1628+
})
15031629
})

packages/framer-motion/src/components/AnimatePresence/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ export const AnimatePresence = ({
189189
if (exitingComponents.current.has(key)) {
190190
return
191191
}
192-
exitingComponents.current.add(key)
193192

194193
if (exitComplete.has(key)) {
194+
exitingComponents.current.add(key)
195195
exitComplete.set(key, true)
196196
} else {
197197
return

0 commit comments

Comments
 (0)