From 7efe00e3cb6d263fabf876cab3fc3163761d6592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 3 Oct 2026 13:34:27 -0400 Subject: [PATCH] fix(studio): a nudge on a tweened layer keeps its keyframe while the preview reloads The drag route chose a static gsap.set when the live preview showed no position tween and the source tween was not keyframed. A flat from() left that choice to the live read alone, so a nudge saved while the preview was reloading wrote a static set. The current file now counts too. --- .../src/hooks/gsapRuntimeBridge.test.ts | 40 +++++++++++++++++++ .../studio/src/hooks/gsapRuntimeBridge.ts | 17 +++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index 4c4fb101f39..1b8d0b110fb 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -155,6 +155,46 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele expect(mutation.type).not.toBe("add-keyframe"); }); + // CI 37120535633: a nudge on a flat from() wrote a static set while the preview reloaded. + it("keeps the tween route when the file animates position but the runtime reads empty", async () => { + const fromTween = { + ...stalePositionAnim, + id: "#puck-b-from-0-position", + method: "from", + properties: { x: -60 }, + position: 0, + resolvedStart: 0, + } as GsapAnimation; + const commitMutation = vi.fn(); + + await tryGsapDragIntercept( + selection, + { x: 5, y: 0 }, + [fromTween], + fakeIframe("puck-b", []), + commitMutation, + vi.fn().mockResolvedValue([fromTween]), + ); + + const methods = commitMutation.mock.calls.map(([, mutation]) => mutation.method); + expect(methods).not.toContain("set"); + }); + + it("still commits a static set when the current file no longer has the tween", async () => { + const commitMutation = vi.fn(); + + await tryGsapDragIntercept( + selection, + { x: -50, y: 30 }, + [stalePositionAnim], + fakeIframe("puck-b", []), + commitMutation, + vi.fn().mockResolvedValue([]), + ); + + expect(commitMutation.mock.calls[0]?.[1]).toMatchObject({ type: "add", method: "set" }); + }); + it("forwards one complete instantPatch when atomically updating an existing static set", async () => { const commitMutation = vi.fn(); const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index b4d4c85da1b..cafffc3d7d1 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -187,11 +187,15 @@ function dragRoute( posAnim: GsapAnimation | null, iframe: HTMLIFrameElement | null, selector: string, - altKey?: boolean, + altKey: boolean | undefined, + fileAnimations: GsapAnimation[] | null, ): "static" | "whole-path" | "at-playhead" { const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS); const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0; - if (!hasNonHold && !hasKeyframedPosTween) return "static"; + const fileTween = fileAnimations && findGsapPositionAnimation(fileAnimations, selector); + const fileAnimatesPosition = + !!fileTween && !isInstantHold(fileTween) && resolveTweenDuration(fileTween) > 0; + if (!hasNonHold && !hasKeyframedPosTween && !fileAnimatesPosition) return "static"; // Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default. return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead"; } @@ -213,7 +217,7 @@ async function planDrag( : own; const resolved = await resolveGroupTween("position", animations, selection, async () => {}); const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector); - const route = dragRoute(posAnim, iframe, selector, options.altKey); + const route = dragRoute(posAnim, iframe, selector, options.altKey, own); if (route === "static") return { status: "persisted" }; if (!posAnim) { return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" }; @@ -308,7 +312,8 @@ export async function tryGsapDragIntercept( } const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 }; - const route = dragRoute(posAnim, iframe, selector, options?.altKey); + const fileAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : null; + const route = dragRoute(posAnim, iframe, selector, options?.altKey, fileAnimations); if (route === "static") { const existingSet = posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector @@ -329,8 +334,8 @@ export async function tryGsapDragIntercept( // `animations` list can lag behind the file after a prior mutation changed // the tween's position/method (which changes the ID). Re-fetch to get the // current ID and avoid a stale-ID remove that creates duplicate tweens. - if (fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); + if (fileAnimations) { + const fresh = fileAnimations; const freshMatch = fresh.find((a) => a.id === posAnim!.id) ?? pickClosestToPlayhead(