diff --git a/dev/react/src/tests/animate-cancel-removes-styles.tsx b/dev/react/src/tests/animate-cancel-removes-styles.tsx new file mode 100644 index 0000000000..d2b1ecb0bd --- /dev/null +++ b/dev/react/src/tests/animate-cancel-removes-styles.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef, useState } from "react" +import { animate } from "framer-motion" + +export const App = () => { + const ref = useRef(null) + const [result, setResult] = useState("running") + + useEffect(() => { + if (!ref.current) return + + const animation = animate( + ref.current, + { opacity: 1 }, + { duration: 0.1 } + ) + + // Wait for animation to finish, then cancel + const timeout = setTimeout(() => { + const before = ref.current!.style.opacity + + animation.cancel() + + const after = ref.current!.style.opacity + + if (before === "1" && after === "") { + setResult("success") + } else { + setResult(`fail:before=${before},after=${after}`) + } + }, 500) + + return () => clearTimeout(timeout) + }, []) + + return ( +
+ {result} +
+ ) +} diff --git a/packages/framer-motion/cypress/integration/animate-cancel-removes-styles.ts b/packages/framer-motion/cypress/integration/animate-cancel-removes-styles.ts new file mode 100644 index 0000000000..552c22ee2d --- /dev/null +++ b/packages/framer-motion/cypress/integration/animate-cancel-removes-styles.ts @@ -0,0 +1,11 @@ +describe("animation.cancel() removes persisted styles", () => { + it("Removes persisted inline style when cancel is called after completion", () => { + cy.visit("?test=animate-cancel-removes-styles") + .wait(3000) + .get("#box") + .then(($el) => { + const text = $el.text() + expect(text).to.equal("success") + }) + }) +}) diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index b0a7a1e96a..c382a7496d 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -517,6 +517,7 @@ export class JSAnimation cancel() { this.holdTime = null this.startTime = 0 + this.state = "running" this.tick(0) this.teardown() this.options.onCancel?.() diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index 93f8201918..6671330b17 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -4,7 +4,7 @@ import { noop, secondsToMilliseconds, } from "motion-utils" -import { setStyle } from "../render/dom/style-set" +import { removeStyle, setStyle } from "../render/dom/style-set" import { supportsScrollTimeline } from "../utils/supports/scroll-timeline" import { getFinalKeyframe } from "./keyframes/get-final" import { @@ -148,6 +148,11 @@ export class NativeAnimation try { this.animation.cancel() } catch (e) {} + + const { element, name } = this.options || {} + if (element && name && !this.isPseudoElement) { + removeStyle(element, name) + } } stop() { @@ -165,7 +170,11 @@ export class NativeAnimation this.commitStyles() } - if (!this.isPseudoElement) this.cancel() + if (!this.isPseudoElement) { + try { + this.animation.cancel() + } catch (e) {} + } } /** diff --git a/packages/motion-dom/src/animation/__tests__/NativeAnimation.test.ts b/packages/motion-dom/src/animation/__tests__/NativeAnimation.test.ts index 517e464bd7..2ea2103153 100644 --- a/packages/motion-dom/src/animation/__tests__/NativeAnimation.test.ts +++ b/packages/motion-dom/src/animation/__tests__/NativeAnimation.test.ts @@ -1,4 +1,5 @@ import { motionValue } from "../../value" +import { NativeAnimation } from "../NativeAnimation" import { NativeAnimationExtended } from "../NativeAnimationExtended" /** @@ -9,6 +10,107 @@ import { NativeAnimationExtended } from "../NativeAnimationExtended" * the scheduled render could apply the correct value, causing a visual flash * back to the initial value. */ +describe("NativeAnimation - cancel removes persisted styles", () => { + let mockAnimation: any + + beforeEach(() => { + mockAnimation = { + cancel: jest.fn(), + onfinish: null, + playbackRate: 1, + currentTime: 300, + playState: "running", + effect: { + getComputedTiming: () => ({ duration: 300 }), + updateTiming: jest.fn(), + }, + } + + Element.prototype.animate = jest + .fn() + .mockImplementation(() => mockAnimation) + }) + + afterEach(() => { + ;(Element.prototype as any).animate = undefined + jest.restoreAllMocks() + }) + + test("cancel() removes persisted inline style after animation finishes", () => { + const element = document.createElement("div") + const mv = motionValue(0) + + const anim = new NativeAnimationExtended({ + element, + name: "opacity", + keyframes: [0, 1], + motionValue: mv, + finalKeyframe: 1, + onComplete: jest.fn(), + duration: 300, + ease: "easeOut", + } as any) + + // Simulate the WAAPI onfinish event firing + mockAnimation.onfinish?.() + + // After finish, inline style should be persisted + expect(element.style.opacity).toBe("1") + + // Now cancel - should remove the persisted style + anim.cancel() + expect(element.style.opacity).toBe("") + }) + + test("cancel() removes persisted inline style for CSS custom properties", () => { + const element = document.createElement("div") + const mv = motionValue(0) + + const anim = new NativeAnimationExtended({ + element, + name: "--my-color", + keyframes: ["red", "blue"], + motionValue: mv, + finalKeyframe: "blue", + onComplete: jest.fn(), + duration: 300, + ease: "easeOut", + } as any) + + // Simulate finish + mockAnimation.onfinish?.() + expect(element.style.getPropertyValue("--my-color")).toBe("blue") + + // Cancel should remove + anim.cancel() + expect(element.style.getPropertyValue("--my-color")).toBe("") + }) + + test("stop() preserves committed inline styles", () => { + const element = document.createElement("div") + document.body.appendChild(element) + + const anim = new NativeAnimation({ + element, + name: "opacity", + keyframes: [0, 1], + finalKeyframe: 1, + onComplete: jest.fn(), + duration: 300, + ease: "easeOut", + } as any) + + // Mock commitStyles to set inline style (simulating WAAPI behavior) + mockAnimation.commitStyles = jest.fn(() => { + element.style.opacity = "0.5" + }) + + // stop() should preserve the committed style + anim.stop() + expect(element.style.opacity).toBe("0.5") + }) +}) + describe("NativeAnimation - onfinish style commit", () => { let mockAnimation: any diff --git a/packages/motion-dom/src/render/dom/style-set.ts b/packages/motion-dom/src/render/dom/style-set.ts index b81b164d6f..ac17634772 100644 --- a/packages/motion-dom/src/render/dom/style-set.ts +++ b/packages/motion-dom/src/render/dom/style-set.ts @@ -10,3 +10,12 @@ export function setStyle( ? element.style.setProperty(name, value as string) : (element.style[name as any] = value as string) } + +export function removeStyle( + element: HTMLElement | SVGElement, + name: string +) { + isCSSVar(name) + ? element.style.removeProperty(name) + : (element.style[name as any] = "") +} diff --git a/tests/animate/animate.spec.ts b/tests/animate/animate.spec.ts index db2391424e..15e8b91d06 100644 --- a/tests/animate/animate.spec.ts +++ b/tests/animate/animate.spec.ts @@ -112,19 +112,19 @@ test.describe("animate() methods", () => { }) }) - test("cancel() after finish is a no-op", async ({ page }) => { + test("cancel() after finish removes persisted styles", async ({ page }) => { await waitForAnimation( "animate/animate-cancel-after-finish.html", page ) await eachBox(page, async (box) => { const id = await box.getAttribute("id") - // cancel() after finish should not revert — the final value is committed + // cancel() after finish should revert — removing persisted inline styles const boundingBox = await box.boundingBox() expect( boundingBox?.x, - `${id} should remain at final position after cancel` - ).toBeCloseTo(100) + `${id} should revert to original position after cancel` + ).toBeCloseTo(0) }) })