From 9f4ed66fa8adfdbc2eb6044c725e1126cd430295 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 12 May 2026 15:40:49 +0200 Subject: [PATCH] Add regression test for accordion rapid-click exit animation (#2674) Covers an accordion item with a keyed motion.section inside AnimatePresence under rapid toggle sequences. Verifies that re-opening mid-exit, closing mid-enter, and 5x rapid toggles all settle into the correct final state. Co-Authored-By: Claude Opus 4.7 --- .../animate-presence-accordion-rapid.tsx | 58 +++++++++++++ .../animate-presence-accordion-rapid.ts | 84 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 dev/react/src/tests/animate-presence-accordion-rapid.tsx create mode 100644 packages/framer-motion/cypress/integration/animate-presence-accordion-rapid.ts diff --git a/dev/react/src/tests/animate-presence-accordion-rapid.tsx b/dev/react/src/tests/animate-presence-accordion-rapid.tsx new file mode 100644 index 0000000000..c518f12e59 --- /dev/null +++ b/dev/react/src/tests/animate-presence-accordion-rapid.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useState } from "react" + +const items = [ + { id: "a", title: "Item A" }, + { id: "b", title: "Item B" }, + { id: "c", title: "Item C" }, +] + +function AccordionItem({ id, title }: { id: string; title: string }) { + const [open, setOpen] = useState(false) + + return ( +
+ + + {open && ( + +
+ Content for {title} +
+
+ )} +
+
+ ) +} + +export const App = () => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ) +} diff --git a/packages/framer-motion/cypress/integration/animate-presence-accordion-rapid.ts b/packages/framer-motion/cypress/integration/animate-presence-accordion-rapid.ts new file mode 100644 index 0000000000..7b9f907b68 --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-presence-accordion-rapid.ts @@ -0,0 +1,84 @@ +describe("AnimatePresence accordion rapid click (#2674)", () => { + it("Toggling open mid-exit leaves the panel visible at full height", () => { + cy.visit("?test=animate-presence-accordion-rapid") + .wait(200) + .get('[data-panel="a"]') + .should("not.exist") + // Open + .get('[data-id="a"]') + .trigger("click", { force: true }) + // Wait for full open animation + .wait(800) + // Close + .get('[data-id="a"]') + .trigger("click", { force: true }) + // Mid-exit (250ms into 500ms exit), re-open + .wait(250) + .get('[data-id="a"]') + .trigger("click", { force: true }) + // Wait for animation to settle + .wait(1500) + // Panel should exist at full height + .get('[data-panel="a"]') + .should("exist") + .then(($el: any) => { + const height = parseFloat( + getComputedStyle($el[0] as HTMLElement).height + ) + const opacity = parseFloat( + getComputedStyle($el[0] as HTMLElement).opacity + ) + expect(height).to.be.greaterThan(100) + expect(opacity).to.equal(1) + }) + }) + + it("Toggling closed mid-enter removes the panel", () => { + cy.visit("?test=animate-presence-accordion-rapid") + .wait(200) + // Open + .get('[data-id="a"]') + .trigger("click", { force: true }) + // Mid-enter (250ms into 500ms enter), close + .wait(250) + .get('[data-id="a"]') + .trigger("click", { force: true }) + // Wait for exit to finish + .wait(1500) + .get('[data-panel="a"]') + .should("not.exist") + }) + + it("Rapidly toggling many times ends in correct state (open)", () => { + cy.visit("?test=animate-presence-accordion-rapid") + .wait(200) + // 5 rapid clicks → ends open + .get('[data-id="a"]') + .trigger("click", { force: true }) + .wait(50) + .get('[data-id="a"]') + .trigger("click", { force: true }) + .wait(50) + .get('[data-id="a"]') + .trigger("click", { force: true }) + .wait(50) + .get('[data-id="a"]') + .trigger("click", { force: true }) + .wait(50) + .get('[data-id="a"]') + .trigger("click", { force: true }) + .wait(2000) + .get('[data-panel="a"]') + .should("exist") + .then(($el: any) => { + const height = parseFloat( + getComputedStyle($el[0] as HTMLElement).height + ) + const opacity = parseFloat( + getComputedStyle($el[0] as HTMLElement).opacity + ) + expect(height).to.be.greaterThan(100) + expect(opacity).to.equal(1) + }) + }) +})