From dff3634ea89d8834a41f2fd935111d9f5530a637 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 1 Jul 2026 21:55:31 -0700 Subject: [PATCH] fix(studio): don't crash resizing an element whose keyframes were removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitWholePropertyOffset reduced the tween's keyframe list to find the "nearest" stop without an initial value. When a to()/from() tween had been collapsed to a zero-duration immediateRender hold (what removeAllKeyframes leaves behind), synthesizeFlatTweenKeyframes correctly treats it as a static hold and returns null, leaving an empty keyframe list — so the reduce threw "Reduce of empty array with no initial value". Reachable by resizing such an element with auto-keyframe recording off. With no keyframe shape to preserve, persist the flat value directly via an update-properties mutation instead. --- .../gsapWholePropertyOffsetCommit.test.ts | 26 +++++++++++++++++++ .../hooks/gsapWholePropertyOffsetCommit.ts | 15 ++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts index aff002556..9eddc4ab5 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts @@ -133,4 +133,30 @@ describe("commitWholePropertyOffset", () => { }); expect(keyframes[1]).toEqual({ percentage: 100, properties: { x: 120, opacity: 0.5 } }); }); + + it("persists a flat update instead of crashing when the tween has no synthesizable shape", async () => { + // Regression: removeAllKeyframesFromScript collapses a keyframed tween into a + // zero-duration immediateRender hold — synthesizeFlatTweenKeyframes treats that + // as a static hold (returns null), so `kfs` is empty. Resizing this element with + // auto-keyframe off used to call `kfs.reduce(...)` with no initial value, which + // throws "Reduce of empty array with no initial value". + const anim = { + id: "#box-hold", + targetSelector: "#box", + method: "to", + resolvedStart: 0, + duration: 0, + properties: { width: 200 }, + extras: { immediateRender: "__raw:true" }, + } as unknown as GsapAnimation; + + const { mutations, callbacks } = recordingCallbacks(); + await expect( + commitWholePropertyOffset(selection(), anim, { width: 300 }, 0, null, callbacks, "Resize"), + ).resolves.toBeUndefined(); + + expect(mutations).toEqual([ + { type: "update-properties", animationId: "#box-hold", properties: { width: 300 } }, + ]); + }); }); diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts index 6363363c4..fff1f841b 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts @@ -43,9 +43,18 @@ export async function commitWholePropertyOffset( typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0); const kfs = - effectiveAnim.keyframes?.keyframes ?? - synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes ?? - []; + effectiveAnim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes; + if (!kfs || kfs.length === 0) { + // A `to()`/`from()` collapsed to a zero-duration immediateRender hold (what + // removeAllKeyframesFromScript leaves behind) has no shape to preserve — + // just persist the flat value instead of replacing with an empty keyframe list. + await callbacks.commitMutation( + selection, + { type: "update-properties", animationId: effectiveAnim.id, properties: newValues }, + { label, softReload: true }, + ); + return; + } const nearest = kfs.reduce((best, kf) => Math.abs(kf.percentage - currentPct) < Math.abs(best.percentage - currentPct) ? kf : best, );