From 659e22656e51aef54d079b3822fa40086d93726b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 18:50:18 +0200 Subject: [PATCH] fix(studio): switch keyframe ease modes optimistically --- packages/core/package-subpaths.json | 6 + packages/core/package.json | 10 ++ packages/core/tsconfig.json | 1 + .../components/editor/AnimationCard.test.tsx | 51 ++++++-- .../editor/EaseCurveSection.test.tsx | 111 ++++++++++++++++++ .../components/editor/EaseCurveSection.tsx | 84 ++++++++++--- .../components/editor/holdEaseSeek.test.ts | 37 ++++++ 7 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 packages/studio/src/components/editor/holdEaseSeek.test.ts diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index f3505daa6..62bc92fe0 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -110,6 +110,12 @@ "types": "./dist/runtime/clipTree.d.ts", "environments": ["browser", "bun", "node"] }, + "./runtime/custom-ease": { + "source": "./src/runtime/customEase.ts", + "runtime": "./dist/runtime/customEase.js", + "types": "./dist/runtime/customEase.d.ts", + "environments": ["browser", "bun", "node"] + }, "./runtime/start-expression": { "source": "./src/runtime/startExpression.ts", "runtime": "./dist/runtime/startExpression.js", diff --git a/packages/core/package.json b/packages/core/package.json index 3255b1fe1..f33dee17c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -119,6 +119,12 @@ "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, + "./runtime/custom-ease": { + "bun": "./src/runtime/customEase.ts", + "node": "./dist/runtime/customEase.js", + "import": "./src/runtime/customEase.ts", + "types": "./src/runtime/customEase.ts" + }, "./runtime/start-expression": { "bun": "./src/runtime/startExpression.ts", "node": "./dist/runtime/startExpression.js", @@ -353,6 +359,10 @@ "import": "./dist/runtime/clipTree.js", "types": "./dist/runtime/clipTree.d.ts" }, + "./runtime/custom-ease": { + "import": "./dist/runtime/customEase.js", + "types": "./dist/runtime/customEase.d.ts" + }, "./runtime/start-expression": { "import": "./dist/runtime/startExpression.js", "types": "./dist/runtime/startExpression.d.ts" diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d37173bc7..571e3c2a6 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,6 +15,7 @@ }, "files": [ "src/runtime/clipTree.ts", + "src/runtime/customEase.ts", "src/runtime/mediaVolumeEnvelope.ts", "src/runtime/positionEdits.ts", "src/runtime/protocol.ts", diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx index 19b455724..65cf0658b 100644 --- a/packages/studio/src/components/editor/AnimationCard.test.tsx +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -85,10 +85,15 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefi ); } +function openSegment(host: HTMLElement, label: string): void { + const segment = findButton(host, label); + expect(segment).toBeDefined(); + act(() => segment?.click()); +} + function selectPreset(host: HTMLElement, presetId: string): string { const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`); - const dropdown = host.querySelector("[data-ease-type-dropdown]"); expect(dropdown).not.toBeNull(); act(() => dropdown?.click()); @@ -174,9 +179,7 @@ describe("AnimationCard", () => { it("tracks a committed segment ease alongside the existing update", () => { const onEaseCommit = vi.fn(); const view = renderFocusCard(null, onEaseCommit, true); - const segment = findButton(view.host, "0% → 50%"); - expect(segment).toBeDefined(); - act(() => segment?.click()); + openSegment(view.host, "0% → 50%"); const ease = selectPreset(view.host, "quad-out"); expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease); @@ -221,7 +224,6 @@ describe("AnimationCard", () => { vi.fn(), onUpdateSegmentEase, ); - const ease = selectPreset(view.host, "quad-out"); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease); @@ -258,7 +260,40 @@ function baseAnimation(overrides: Partial = {}): GsapAnimation { ...overrides, } as GsapAnimation; } + describe("AnimationCard ease editing", () => { + it.each([ + ["spring", "power2.out", "spring(0.42)", "Spring bounce"], + ["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"], + ["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"], + ] as const)( + "commits and immediately displays the %s default when a keyframe segment switches mode", + (mode, currentEase, ease, fieldLabel) => { + const onUpdateKeyframeEase = vi.fn(); + const animation = baseAnimation({ + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 50, properties: { opacity: 0.5 }, ease: currentEase }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + }); + const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation); + + openSegment(view.host, "0% → 50%"); + const modeButton = view.host.querySelector(`[data-ease-mode="${mode}"]`); + expect(modeButton).not.toBeNull(); + act(() => modeButton?.click()); + + expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); + expect(modeButton?.getAttribute("aria-checked")).toBe("true"); + expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull(); + act(() => view.root.unmount()); + }, + ); + it("commits one preset change to the selected keyframe segment", () => { const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ @@ -273,11 +308,7 @@ describe("AnimationCard ease editing", () => { }); const view = renderCard({ animation, onUpdateKeyframeEase }); - const segment = Array.from(view.host.querySelectorAll("button")).find((button) => - button.textContent?.includes("0% → 50%"), - ); - expect(segment).toBeDefined(); - act(() => segment?.click()); + openSegment(view.host, "0% → 50%"); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); diff --git a/packages/studio/src/components/editor/EaseCurveSection.test.tsx b/packages/studio/src/components/editor/EaseCurveSection.test.tsx index 59eae3e3f..484392fe1 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.test.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -3,7 +3,10 @@ import React, { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { parseWiggleEase } from "@hyperframes/core/wiggle-ease"; import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection"; +import { resolveEaseCurveTuple } from "./gsapAnimationConstants"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -76,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn( return { host, root, onCustomEaseCommit }; } +function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const renderEase = (ease: string) => { + act(() => + root.render(), + ); + }; + renderEase(initialEase); + return { host, root, onCustomEaseCommit, renderEase }; +} + function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void { const toggle = host.querySelector(`[data-ease-mode="${mode}"]`); expect(toggle).not.toBeNull(); @@ -291,12 +307,107 @@ describe("EaseCurveSection preset grid", () => { clickMode(host, "spring"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)"); + expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42); clickMode(host, "curve"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)"); + expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]); clickMode(host, "wiggle"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)"); + expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({ + wiggles: 3, + type: "easeInOut", + amplitude: 0.12, + }); + expect(onCustomEaseCommit).toHaveBeenCalledTimes(3); + + act(() => root.unmount()); + }); + + it("keeps an optimistic mode visible through its canonical prop round-trip", () => { + const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull(); + + renderEase("spring(0.42)"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull(); + expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)"); + + act(() => root.unmount()); + }); + + // Two switches before the first commit round-trips: the commits serialize, so + // the older value arrives while the newer one is still in flight. Repainting + // it would flash wiggle, spring, wiggle in the panel. + it("ignores an older in-flight commit arriving after a newer switch", () => { + const { host, root, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + clickMode(host, "wiggle"); + renderEase("spring(0.42)"); + + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + renderEase("wiggle(3,easeInOut,0.12)"); + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => root.unmount()); + }); + + // The commit is fire-and-forget: a rejected write or one that lands as a + // no-op never changes `ease`, so nothing else can retire the optimistic + // value and the panel would keep claiming a curve that was never saved. + it("falls back to the committed ease when the commit never round-trips", () => { + vi.useFakeTimers(); + try { + const { host, root } = renderControlledSection("power2.out"); + + clickMode(host, "spring"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => vi.advanceTimersByTime(2000)); + + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "false", + ); + expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => root.unmount()); + } finally { + vi.useRealTimers(); + } + }); + + it("replaces an optimistic mode when the canonical prop changes externally", () => { + const { host, root, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + renderEase("wiggle(2,uniform,0.3)"); + + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "false", + ); + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull(); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 8c94e8be4..f2f6ce030 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -321,6 +321,13 @@ function EaseParameterField({ return ; } +/** + * How long an optimistically painted ease may outlive its commit. Long enough + * for a normal write-reparse-rerender round trip, short enough that a dropped + * write self-corrects while the author is still looking at the panel. + */ +const PENDING_EASE_TIMEOUT_MS = 2000; + export function EaseCurveSection({ ease, onCustomEaseCommit, @@ -330,12 +337,20 @@ export function EaseCurveSection({ onCustomEaseCommit: (ease: string) => void; collidingAnimationTargets?: AnimationKeyframeTarget[]; }) { - const springBounce = parseSpringBounce(ease); + // The ease this section painted optimistically, still waiting for its commit + // to round-trip back through the `ease` prop. + const [pendingEase, setPendingEase] = useState(null); + // Every value committed and not yet seen coming back, oldest first. It takes + // the whole queue, not just the latest, to tell an older commit echoing back + // apart from an edit made somewhere else. + const inFlightEasesRef = useRef([]); + const displayedEase = pendingEase ?? ease; + const springBounce = parseSpringBounce(displayedEase); const isSpring = springBounce !== null; - const wiggleConfig = parseWiggleEase(ease); + const wiggleConfig = parseWiggleEase(displayedEase); const isWiggle = wiggleConfig !== null; const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve"; - const curve = resolveEditableCurve(ease, springBounce); + const curve = resolveEditableCurve(displayedEase, springBounce); const [draft, setDraft] = useState(null); const [hover, setHover] = useState<"p1" | "p2" | null>(null); @@ -349,8 +364,43 @@ export function EaseCurveSection({ // `ease` changes, `curve` already equals the draft, so the handoff is seamless. useEffect(() => { setDraft(null); + const inFlight = inFlightEasesRef.current; + const landed = inFlight.indexOf(ease); + if (landed < 0) { + // A value this section never sent: someone else edited the ease, so the + // real value wins over anything optimistic still on screen. + inFlight.length = 0; + setPendingEase(null); + return; + } + // One of this section's own commits came back. Everything sent before it + // is settled with it, but a NEWER commit may still be in flight, and + // repainting this older value while waiting for that one is the + // wiggle-then-spring-then-wiggle flicker of a fast double switch. + inFlight.splice(0, landed + 1); + if (inFlight.length === 0) setPendingEase(null); }, [ease]); + // A commit is fire-and-forget, so a write that is rejected or lands as a + // no-op never changes `ease`, and the optimistic value would sit on screen + // claiming a curve the composition does not have. Nothing downstream reports + // that failure, so the display is time-bounded instead: fall back to the + // committed truth when the round trip does not arrive. + useEffect(() => { + if (pendingEase === null) return; + const timer = setTimeout(() => { + inFlightEasesRef.current.length = 0; + setPendingEase(null); + }, PENDING_EASE_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [pendingEase]); + + const commitEase = (nextEase: string) => { + inFlightEasesRef.current.push(nextEase); + setPendingEase(nextEase); + onCustomEaseCommit(nextEase); + }; + const activeTuple = draft ?? curve; const displayTuple = activeTuple ?? DEFAULT_CURVE; const [x1, y1, x2, y2] = displayTuple; @@ -361,8 +411,12 @@ export function EaseCurveSection({ const a1 = { x: xToSvg(1), y: yToSvg(1) }; const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) }; const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) }; - const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple); - const showGraph = activeTuple !== null || isWiggle || ease === "hold"; + // Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch + // paints immediately instead of waiting for the committed prop to come back. + const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple); + const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold"; + // `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat + // step) but has no editable control points to drag. const showHandles = curve !== null && !isSpring && !isWiggle; const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { @@ -397,10 +451,9 @@ export function EaseCurveSection({ if (!draggingRef.current || !draft) return; draggingRef.current = null; const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; - // Clear after the synchronous parent commit settles. This also clears a - // same-string commit, where the `ease` dependency effect would not run. - onCustomEaseCommit(`custom(${path})`); - queueMicrotask(() => setDraft(null)); + // Commit only — the draft stays on screen and is cleared by the effect above + // once the committed `ease` prop comes back, so the curve never flickers. + commitEase(`custom(${path})`); }; const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent) => { @@ -409,25 +462,26 @@ export function EaseCurveSection({ event.preventDefault(); event.stopPropagation(); setDraft(next); - onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); - queueMicrotask(() => setDraft(null)); + // Same no-flicker contract as the pointer path: commit and let the effect + // clear the draft, rather than dropping it on the next microtask. + commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); }; const top = yToSvg(1); const bottom = yToSvg(0); const left = xToSvg(0); const right = xToSvg(1); - const label = resolveEditorLabel(ease, springBounce, isWiggle); + const label = resolveEditorLabel(displayedEase, springBounce, isWiggle); return (
- + {collidingAnimationTargets && collidingAnimationTargets.length > 1 && (

Applies to {collidingAnimationTargets.length} animations

)} - + {MODE_LABELS[mode]} ease editor selected @@ -568,7 +622,7 @@ export function EaseCurveSection({ springBounce={springBounce} wiggleConfig={wiggleConfig} tuple={displayTuple} - onCommit={onCustomEaseCommit} + onCommit={commitEase} /> ) : ( diff --git a/packages/studio/src/components/editor/holdEaseSeek.test.ts b/packages/studio/src/components/editor/holdEaseSeek.test.ts new file mode 100644 index 000000000..d1a1f6f05 --- /dev/null +++ b/packages/studio/src/components/editor/holdEaseSeek.test.ts @@ -0,0 +1,37 @@ +// Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold` +// ease against the engine that actually runs it, and gsap is a studio +// dependency. core's own customEase.test.ts covers the resolver in isolation. +import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease"; +import { gsap } from "gsap"; +import { describe, expect, it } from "vitest"; + +describe("Studio hold ease", () => { + it("holds the start value under seek until the destination time", () => { + const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) }; + expect(installStudioCustomEase(runtimeGsap)).toBe(true); + const hold = runtimeGsap.parseEase("hold"); + expect(hold).toBeTypeOf("function"); + if (typeof hold !== "function") return; + + const target = { value: 0 }; + const timeline = gsap.timeline({ paused: true }).to( + target, + { + value: 100, + duration: 2, + ease: hold, + }, + 0, + ); + + timeline.seek(0.5); + expect(target.value).toBe(0); + timeline.seek(1); + expect(target.value).toBe(0); + timeline.seek(1.99); + expect(target.value).toBe(0); + timeline.seek(2); + expect(target.value).toBe(100); + timeline.kill(); + }); +});