From d9d94e2708667cf79b5dff1cb06de19a07a2f8c6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 1 Mar 2023 13:47:30 +0000 Subject: [PATCH 1/3] Making event handlers async to allow React to batch state --- .../drag/VisualElementDragControls.ts | 10 ++++-- packages/framer-motion/src/gestures/hover.ts | 3 +- .../framer-motion/src/gestures/pan/index.ts | 17 ++++++++-- packages/framer-motion/src/gestures/press.ts | 32 +++++++++++++------ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 658958061c..91cc1561cd 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -31,6 +31,7 @@ import { calcLength } from "../../projection/geometry/delta-calc" import { mix } from "../../utils/mix" import { percent } from "../../value/types/numbers/units" import { createMotionValueAnimation } from "../../animation" +import { sync } from "../../frameloop" export const elementDragControls = new WeakMap< VisualElement, @@ -149,7 +150,10 @@ export class VisualElementDragControls { }) // Fire onDragStart event - onDragStart && onDragStart(event, info) + if (onDragStart) { + sync.update(() => onDragStart(event, info)) + } + const { animationState } = this.visualElement animationState && animationState.setActive("whileDrag", true) } @@ -223,7 +227,9 @@ export class VisualElementDragControls { this.startAnimation(velocity) const { onDragEnd } = this.getProps() - onDragEnd && onDragEnd(event, info) + if (onDragEnd) { + sync.update(() => onDragEnd(event, info)) + } } private cancel() { diff --git a/packages/framer-motion/src/gestures/hover.ts b/packages/framer-motion/src/gestures/hover.ts index 3a794b037b..c72af2c40f 100644 --- a/packages/framer-motion/src/gestures/hover.ts +++ b/packages/framer-motion/src/gestures/hover.ts @@ -4,6 +4,7 @@ import { isDragActive } from "./drag/utils/lock" import { EventInfo } from "../events/types" import type { VisualElement } from "../render/VisualElement" import { Feature } from "../motion/features/Feature" +import { sync } from "../frameloop" function addHoverEvent(node: VisualElement, isActive: boolean) { const eventName = "pointer" + (isActive ? "enter" : "leave") @@ -19,7 +20,7 @@ function addHoverEvent(node: VisualElement, isActive: boolean) { } if (props[callbackName]) { - props[callbackName](event, info) + sync.update(() => props[callbackName](event, info)) } } diff --git a/packages/framer-motion/src/gestures/pan/index.ts b/packages/framer-motion/src/gestures/pan/index.ts index 1b6b69cb33..393f1d7435 100644 --- a/packages/framer-motion/src/gestures/pan/index.ts +++ b/packages/framer-motion/src/gestures/pan/index.ts @@ -2,6 +2,15 @@ import { PanInfo, PanSession } from "./PanSession" import { addPointerEvent } from "../../events/add-pointer-event" import { Feature } from "../../motion/features/Feature" import { noop } from "../../utils/noop" +import { sync } from "../../frameloop" + +type PanEventHandler = (event: PointerEvent, info: PanInfo) => void +const asyncHandler = + (handler?: PanEventHandler) => (event: PointerEvent, info: PanInfo) => { + if (handler) { + sync.update(() => handler(event, info)) + } + } export class PanGesture extends Feature { private session?: PanSession @@ -21,12 +30,14 @@ export class PanGesture extends Feature { this.node.getProps() return { - onSessionStart: onPanSessionStart, - onStart: onPanStart, + onSessionStart: asyncHandler(onPanSessionStart), + onStart: asyncHandler(onPanStart), onMove: onPan, onEnd: (event: PointerEvent, info: PanInfo) => { delete this.session - onPanEnd && onPanEnd(event, info) + if (onPanEnd) { + sync.update(() => onPanEnd(event, info)) + } }, } } diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index 8543208a0d..5648209be8 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -10,6 +10,7 @@ import { pipe } from "../utils/pipe" import { isDragActive } from "./drag/utils/lock" import { isNodeOrChild } from "./utils/is-node-or-child" import { noop } from "../utils/noop" +import { sync } from "../frameloop" function fireSyntheticPointerEvent( name: string, @@ -39,7 +40,9 @@ export class PressGesture extends Feature { this.node.animationState.setActive("whileTap", true) } - onTapStart && onTapStart(event, info) + if (onTapStart) { + sync.update(() => onTapStart(event, info)) + } } private checkPressEnd() { @@ -73,13 +76,15 @@ export class PressGesture extends Feature { const { onTap, onTapCancel } = this.node.getProps() - /** - * We only count this as a tap gesture if the event.target is the same - * as, or a child of, this component's element - */ - !isNodeOrChild(this.node.current, endEvent.target as Element) - ? onTapCancel && onTapCancel(endEvent, endInfo) - : onTap && onTap(endEvent, endInfo) + sync.update(() => { + /** + * We only count this as a tap gesture if the event.target is the same + * as, or a child of, this component's element + */ + !isNodeOrChild(this.node.current, endEvent.target as Element) + ? onTapCancel && onTapCancel(endEvent, endInfo) + : onTap && onTap(endEvent, endInfo) + }) } const removePointerUpListener = addPointerEvent( @@ -109,7 +114,9 @@ export class PressGesture extends Feature { if (!this.checkPressEnd()) return const { onTapCancel } = this.node.getProps() - onTapCancel && onTapCancel(event, info) + if (onTapCancel) { + sync.update(() => onTapCancel(event, info)) + } } private startAccessiblePress = () => { @@ -119,7 +126,12 @@ export class PressGesture extends Feature { const handleKeyup = (keyupEvent: KeyboardEvent) => { if (keyupEvent.key !== "Enter" || !this.checkPressEnd()) return - fireSyntheticPointerEvent("up", this.node.getProps().onTap) + fireSyntheticPointerEvent("up", (event, info) => { + const { onTap } = this.node.getProps() + if (onTap) { + sync.update(() => onTap(event, info)) + } + }) } this.removeEndListeners() From 0d06abf5bc07401de10a382c3d074283ec46a8ea Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 5 Mar 2023 10:44:30 +0000 Subject: [PATCH 2/3] Fixing tests --- .../src/gestures/__tests__/hover.test.tsx | 11 +++-- .../src/gestures/__tests__/press.test.tsx | 48 ++++++++++++++----- .../src/gestures/__tests__/utils.ts | 7 +++ .../gestures/drag/__tests__/index.test.tsx | 15 +++++- .../drag/__tests__/use-drag-controls.test.tsx | 5 +- .../src/motion/__tests__/variant.test.tsx | 5 ++ .../src/motion/__tests__/waapi.test.tsx | 22 +++++++-- 7 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 packages/framer-motion/src/gestures/__tests__/utils.ts diff --git a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx index fd2f0b1ab3..1ec343b1a3 100644 --- a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx @@ -11,7 +11,7 @@ import { transformValues } from "../../motion/__tests__/util-transform-values" import { sync } from "../../frameloop" describe("hover", () => { - test("hover event listeners fire", () => { + test("hover event listeners fire", async () => { const hoverIn = jest.fn() const hoverOut = jest.fn() const Component = () => ( @@ -22,8 +22,13 @@ describe("hover", () => { pointerEnter(container.firstChild as Element) pointerLeave(container.firstChild as Element) - expect(hoverIn).toBeCalledTimes(1) - expect(hoverOut).toBeCalledTimes(1) + return new Promise((resolve) => { + sync.render(() => { + expect(hoverIn).toBeCalledTimes(1) + expect(hoverOut).toBeCalledTimes(1) + resolve() + }) + }) }) test("whileHover applied", async () => { diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 223ceec586..6db75ec66a 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -10,13 +10,14 @@ import { } from "../../../jest.setup" import { drag, MockDrag } from "../drag/__tests__/utils" import { fireEvent } from "@testing-library/dom" +import { nextFrame } from "./utils" const enterKey = { key: "Enter", } describe("press", () => { - test("press event listeners fire", () => { + test("press event listeners fire", async () => { const press = jest.fn() const Component = () => press()} /> @@ -26,10 +27,12 @@ describe("press", () => { pointerDown(container.firstChild as Element) pointerUp(container.firstChild as Element) + await nextFrame() + expect(press).toBeCalledTimes(1) }) - test("press event listeners fire via keyboard", () => { + test("press event listeners fire via keyboard", async () => { const press = jest.fn() const pressStart = jest.fn() const pressCancel = jest.fn() @@ -47,16 +50,20 @@ describe("press", () => { fireEvent.focus(container.firstChild as Element) fireEvent.keyDown(container.firstChild as Element, enterKey) + await nextFrame() + expect(pressStart).toBeCalledTimes(1) fireEvent.keyUp(container.firstChild as Element, enterKey) + await nextFrame() + expect(pressStart).toBeCalledTimes(1) expect(press).toBeCalledTimes(1) expect(pressCancel).toBeCalledTimes(0) }) - test("press cancel event listeners fire via keyboard", () => { + test("press cancel event listeners fire via keyboard", async () => { const press = jest.fn() const pressStart = jest.fn() const pressCancel = jest.fn() @@ -74,16 +81,20 @@ describe("press", () => { fireEvent.focus(container.firstChild as Element) fireEvent.keyDown(container.firstChild as Element, enterKey) + await nextFrame() + expect(pressStart).toBeCalledTimes(1) fireEvent.blur(container.firstChild as Element) + await nextFrame() + expect(pressStart).toBeCalledTimes(1) expect(press).toBeCalledTimes(0) expect(pressCancel).toBeCalledTimes(1) }) - test("press cancel event listeners not fired via keyboard after keyUp", () => { + test("press cancel event listeners not fired via keyboard after keyUp", async () => { const press = jest.fn() const pressStart = jest.fn() const pressCancel = jest.fn() @@ -102,16 +113,20 @@ describe("press", () => { fireEvent.keyDown(container.firstChild as Element, enterKey) fireEvent.keyUp(container.firstChild as Element, enterKey) + await nextFrame() + expect(pressStart).toBeCalledTimes(1) fireEvent.blur(container.firstChild as Element) + await nextFrame() + expect(press).toBeCalledTimes(1) expect(pressStart).toBeCalledTimes(1) expect(pressCancel).toBeCalledTimes(0) }) - test("press event listeners are cleaned up", () => { + test("press event listeners are cleaned up", async () => { const press = jest.fn() const { container, rerender } = render( press()} /> @@ -119,14 +134,16 @@ describe("press", () => { rerender( press()} />) pointerDown(container.firstChild as Element) pointerUp(container.firstChild as Element) + await nextFrame() expect(press).toBeCalledTimes(1) rerender() pointerDown(container.firstChild as Element) pointerUp(container.firstChild as Element) + await nextFrame() expect(press).toBeCalledTimes(1) }) - test("onTapCancel is correctly removed from a component", () => { + test("onTapCancel is correctly removed from a component", async () => { const cancelA = jest.fn() const Component = () => ( @@ -147,15 +164,17 @@ describe("press", () => { pointerDown(a) pointerUp(a) + await nextFrame() expect(cancelA).not.toHaveBeenCalled() pointerDown(b) pointerUp(b) + await nextFrame() expect(cancelA).not.toHaveBeenCalled() }) - test("press event listeners fire if triggered by child", () => { + test("press event listeners fire if triggered by child", async () => { const press = jest.fn() const Component = () => ( press()}> @@ -168,11 +187,12 @@ describe("press", () => { pointerDown(getByTestId("child")) pointerUp(getByTestId("child")) + await nextFrame() expect(press).toBeCalledTimes(1) }) - test("press event listeners fire if triggered by child and released on bound element", () => { + test("press event listeners fire if triggered by child and released on bound element", async () => { const press = jest.fn() const Component = () => ( press()}> @@ -185,11 +205,12 @@ describe("press", () => { pointerDown(getByTestId("child")) pointerUp(container.firstChild as Element) + await nextFrame() expect(press).toBeCalledTimes(1) }) - test("press event listeners fire if triggered by bound element and released on child", () => { + test("press event listeners fire if triggered by bound element and released on child", async () => { const press = jest.fn() const Component = () => ( press()}> @@ -202,11 +223,12 @@ describe("press", () => { pointerDown(container.firstChild as Element) pointerUp(getByTestId("child")) + await nextFrame() expect(press).toBeCalledTimes(1) }) - test("press cancel fires if press released outside element", () => { + test("press cancel fires if press released outside element", async () => { const pressCancel = jest.fn() const Component = () => ( @@ -222,6 +244,7 @@ describe("press", () => { pointerDown(getByTestId("child")) pointerUp(container.firstChild as Element) + await nextFrame() expect(pressCancel).toBeCalledTimes(1) }) @@ -246,6 +269,7 @@ describe("press", () => { await pointer.to(10, 10) pointer.end() + await nextFrame() expect(press).toBeCalledTimes(0) }) @@ -267,11 +291,12 @@ describe("press", () => { const pointer = await drag(getByTestId("pressTarget")).to(0.5, 0.5) pointer.end() + await nextFrame() expect(press).toBeCalledTimes(1) }) - test("press event listeners unset", () => { + test("press event listeners unset", async () => { const press = jest.fn() const Component = () => press()} /> @@ -290,6 +315,7 @@ describe("press", () => { pointerDown(container.firstChild as Element) pointerUp(container.firstChild as Element) + await nextFrame() expect(press).toBeCalledTimes(3) }) diff --git a/packages/framer-motion/src/gestures/__tests__/utils.ts b/packages/framer-motion/src/gestures/__tests__/utils.ts new file mode 100644 index 0000000000..366365e547 --- /dev/null +++ b/packages/framer-motion/src/gestures/__tests__/utils.ts @@ -0,0 +1,7 @@ +import { sync } from "../../frameloop" + +export async function nextFrame() { + return new Promise((resolve) => { + sync.render(() => resolve()) + }) +} diff --git a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx index a97d826d87..7a3968e33b 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { pointerDown, render } from "../../../../jest.setup" import { BoundingBox, motion, motionValue, MotionValue } from "../../../" import { MockDrag, drag, deferred, frame, Point, sleep } from "./utils" +import { nextFrame } from "../../__tests__/utils" describe("drag", () => { test("onDragStart fires", async () => { @@ -19,6 +20,8 @@ describe("drag", () => { pointer.end() + await nextFrame() + expect(onDragStart).toBeCalledTimes(1) }) }) @@ -43,6 +46,8 @@ describe("dragging", () => { pointer.end() + await nextFrame() + expect(onDragStart).toBeCalledTimes(0) }) @@ -60,6 +65,8 @@ describe("dragging", () => { const pointer = await drag(container.firstChild).to(100, 100) pointer.end() + await nextFrame() + expect(onDragEnd).toBeCalledTimes(1) }) @@ -78,6 +85,8 @@ describe("dragging", () => { pointer.end() + await nextFrame() + expect(onDragEnd).not.toBeCalled() }) @@ -105,6 +114,8 @@ describe("dragging", () => { pointer.end() + await nextFrame() + expect(onDragStart).toBeCalledTimes(1) expect(onDragEnd).toBeCalledTimes(1) }) @@ -177,7 +188,7 @@ describe("dragging", () => { const pointer = await drag(getByTestId("draggable")).to(50, 50) pointer.end() - expect(onDragEnd.promise).resolves.toEqual(p) + await expect(onDragEnd.promise).resolves.toEqual(p) }) test("panSessionStart fires", async () => { @@ -194,6 +205,8 @@ describe("dragging", () => { const pointer = await drag(container.firstChild).to(100, 100) pointer.end() + await nextFrame() + expect(onDragStart).toBeCalledTimes(1) }) diff --git a/packages/framer-motion/src/gestures/drag/__tests__/use-drag-controls.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/use-drag-controls.test.tsx index 9d96212814..ca7d810aed 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/use-drag-controls.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/use-drag-controls.test.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { render } from "../../../../jest.setup" import { motion, useDragControls } from "../../../" import { MockDrag, drag } from "./utils" +import { nextFrame } from "../../__tests__/utils" describe("useDragControls", () => { test(".start triggers dragging on a different component", async () => { @@ -34,6 +35,8 @@ describe("useDragControls", () => { pointer.end() + await nextFrame() + expect(onDragStart).toBeCalledTimes(1) }) @@ -67,7 +70,7 @@ describe("useDragControls", () => { ).to(100, 100) pointer.end() - + await nextFrame() expect(onDragStart).toBeCalledTimes(1) }) }) diff --git a/packages/framer-motion/src/motion/__tests__/variant.test.tsx b/packages/framer-motion/src/motion/__tests__/variant.test.tsx index 3311b40a7e..1fc3a8d6bf 100644 --- a/packages/framer-motion/src/motion/__tests__/variant.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/variant.test.tsx @@ -9,6 +9,7 @@ import * as React from "react" import { Variants } from "../../types" import { motionValue } from "../../value" import { useState } from "react" +import { nextFrame } from "../../gestures/__tests__/utils" describe("animate prop as variant", () => { test("animates to set variant", async () => { @@ -978,6 +979,8 @@ describe("animate prop as variant", () => { await wait(20) + await nextFrame() + expect(inner).toHaveStyle("background-color: rgb(150,150,0)") pointerDown(getByTestId("variant-trigger")) @@ -985,6 +988,8 @@ describe("animate prop as variant", () => { await wait(20) + await nextFrame() + expect(inner).toHaveStyle("background-color: rgb(0, 150,150)") }) diff --git a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx index 694ddbb854..2d3ac52a88 100644 --- a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx @@ -5,7 +5,7 @@ import { pointerUp, render, } from "../../../jest.setup" -import { motion, useMotionValue } from "../../" +import { motion, sync, useMotionValue } from "../../" import * as React from "react" import { createRef } from "react" @@ -21,7 +21,9 @@ beforeEach(() => { _keyframes: Keyframe[] | null | PropertyIndexedKeyframes, _options: KeyframeAnimationOptions | number | undefined ) => { - return {} as any + return { + cancel: () => {}, + } as any } ) }) @@ -277,10 +279,15 @@ describe("WAAPI animations", () => { pointerLeave(container.firstChild as Element) rerender() - expect(ref.current!.animate).toBeCalledTimes(2) + return new Promise((resolve) => { + sync.render(() => { + expect(ref.current!.animate).toBeCalledTimes(2) + resolve() + }) + }) }) - test("WAAPI only receives expected number of calls in Framer configuration with tap gestures enabled", () => { + test("WAAPI only receives expected number of calls in Framer configuration with tap gestures enabled", async () => { const ref = createRef() const Component = () => { const [isPressed, setIsPressed] = React.useState(false) @@ -305,7 +312,12 @@ describe("WAAPI animations", () => { pointerUp(container.firstChild as Element) rerender() - expect(ref.current!.animate).toBeCalledTimes(2) + return new Promise((resolve) => { + sync.render(() => { + expect(ref.current!.animate).toBeCalledTimes(2) + resolve() + }) + }) }) test("WAAPI is called with expected arguments", () => { From 4f20da67050cd73617170e1bc58b3bbe1f51d447 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 5 Mar 2023 22:21:10 +0000 Subject: [PATCH 3/3] Fixing tests --- .../src/motion/__tests__/waapi.test.tsx | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx index 2d3ac52a88..21c8bf8547 100644 --- a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx @@ -5,9 +5,10 @@ import { pointerUp, render, } from "../../../jest.setup" -import { motion, sync, useMotionValue } from "../../" +import { motion, useMotionValue } from "../../" import * as React from "react" import { createRef } from "react" +import { nextFrame } from "../../gestures/__tests__/utils" /** * This assignment prevents Jest from complaining about @@ -254,7 +255,7 @@ describe("WAAPI animations", () => { expect(ref.current!.animate).toBeCalled() }) - test("WAAPI only receives expected number of calls in Framer configuration with hover gestures enabled", () => { + test("WAAPI only receives expected number of calls in Framer configuration with hover gestures enabled", async () => { const ref = createRef() const Component = () => { const [isHovered, setIsHovered] = React.useState(false) @@ -276,15 +277,13 @@ describe("WAAPI animations", () => { } const { container, rerender } = render() pointerEnter(container.firstChild as Element) + + await nextFrame() pointerLeave(container.firstChild as Element) + await nextFrame() rerender() - return new Promise((resolve) => { - sync.render(() => { - expect(ref.current!.animate).toBeCalledTimes(2) - resolve() - }) - }) + expect(ref.current!.animate).toBeCalledTimes(2) }) test("WAAPI only receives expected number of calls in Framer configuration with tap gestures enabled", async () => { @@ -309,15 +308,15 @@ describe("WAAPI animations", () => { } const { container, rerender } = render() pointerDown(container.firstChild as Element) + + await nextFrame() pointerUp(container.firstChild as Element) + + await nextFrame() + rerender() - return new Promise((resolve) => { - sync.render(() => { - expect(ref.current!.animate).toBeCalledTimes(2) - resolve() - }) - }) + expect(ref.current!.animate).toBeCalledTimes(2) }) test("WAAPI is called with expected arguments", () => {