feat(studio): GSAP drag/commit/bridge editing infra#1608
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 57f4cd85078f248c420e9aced63c764ae7894a7d (Group 2, sibling to #1607). Cross-read with #1607 read-layer and traced the drag → commit → mutation chain end-to-end. Found one blocker.
Blockers
useGsapAwareEditing.ts:240-246—commitMutationfacade now wraps viauseSafeGsapCommitMutation, which is fire-and-forget (seeuseSafeGsapCommitMutation.ts:46—void commitMutation(...).catch(...), returns void). The new facade callssafeGsapCommit(...)synchronously withoutawaitand the inner async function returnsundefinedimmediately, so the outercommitMutationresolves before the server save lands. Pre-PR wasawait gsapCommitMutation?.(...)— theawaitis gone. This breaks at least one external caller:useEnableKeyframes.ts:153doesawait session.commitMutation(...)(passed throughDomEditContext.tsx:240ascommitMutation: stableCommitMutation). Theawaitnow resolves immediately; if the save errors the toast still fires async, but any caller expecting "after this resolves, the save is durable" is wrong. Suggested fix: either (a) makeuseSafeGsapCommitMutationreturn the inner Promise —return commitMutation(selection, mutation, options).catch(...)— andawait safeGsapCommit(...)in the facade, OR (b) restore the explicitawait gsapCommitMutation?.(...)in the facade and letuseSafeGsapCommitMutationonly apply to the new internalhandleUnroll-style fire-and-forget paths. Worth flagging:handleGsapAwarePathOffsetCommitat line 112 passesgsapCommitMutation(the raw fn) directly totryGsapDragIntercept— so the drag path itself is still awaitable. The regression is on the facadecommitMutationconsumed byuseEnableKeyframes+DomEditContext.
Concerns
-
gsapRuntimeBridge.ts:230(stale-parse guard) — uses #1607'sreadRuntimeKeyframes(iframe, selector). The guard is the right shape (live runtime authoritative over async parse) and matches race-fix preempt-vs-narrow PREEMPT: phantom-resurrection is structurally impossible once the runtime check is in place. But the guard accepts ANY non-null read — including a flat 2-pointto({x})tween, anarcPath, a motion-path tween. Verify the read-layer'sreadTweenreturns non-null for every tween shapefindGsapPositionAnimationwould resolve (includingfrom(),fromTo()). I spot-checkedflatTweenKeyframescovers vars with numeric props — fine. But if the read layer ever returns null for a real-live tween thatfindGsapPositionAnimationmatches (e.g.to({motionPath, ease, duration})where vars has no flat numeric prop AND motionPath shape fails coords filter), the guard misfires and the drag falls to CSS even though the tween is genuine. Edge case but worth a unit test. -
gsapDragCommit.ts:218(gsapLib.set(el, { clearProps })before seek) — this clears props the tween doesn't animate to capture clean interpolated values. Good. But what if the user is mid-drag of a SECOND axis (props they DID intend to set)? The clear is keyed onObject.keys(properties)— the drag's props. After clear → seek → re-read, the dragged-but-unsaved values are now gone from the element. Saved by the commit immediately after, but if the commit fails (toast path now via safe wrapper) the element is left in a different state than before the drag. Worth a fail-path test:commitMutationrejects → does the element end up at the dragged position, the cleared position, or the pre-drag position? Failure-path observability rubric. -
gsapDragCommit.ts:155(parkPlayheadOnKeyframe) —roundTo3(ts + (pct/100) * td). Good idempotent rounding. But: what if the parked seek time falls inside ANOTHER tween's range on a different element / same element via a second tween (per #1607's multi-tween model)? The new read-layer is playhead-aware — parking on one tween's keyframe might surface a different tween in the overlay for the same element. Not a bug per se, just user-confusing if it happens. Probably benign because the tween being edited IS the one whose keyframe was selected, so the playhead lands inside its range. Worth a glance. -
manualEditsDom.ts:253-291— silently-removed check merge-base rubric: the -39 deletion inapplyStudioPathOffsetDraftis the GSAP branch being extracted (not deleted) toapplyStudioPathOffsetViaGsap+ reused in bothapplyStudioPathOffsetandapplyStudioPathOffsetDraft. Verified merge-base; the asymmetry-fix is the real change. Good. Thedata-hf-drag-gsap-base-x/y+data-hf-drag-initial-offset-x/yattribute reads are now in the shared helper — both callers benefit. Nice cleanup. -
useGsapScriptCommits.ts:85-89(applied: "soft" | "full") — the variable is assigned but never read. The fallbackif (applied === "full") reloadPreview()is reachable via the priorapplied = ... ? "soft" : "full"line. Currently dead —appliedis just a local toggle. Either remove or wire to telemetry (trackStudioReloadFallback("soft-failed→full")would be the natural observability hook for "how often does soft fall to full"). Nit. -
useGsapAwareEditing.ts:231—noopCommit = useCallback(async () => {}, [])+gsapCommitMutation ?? noopCommit— ifgsapCommitMutationtoggles between defined/undefined,safeGsapCommitref churns and downstreamuseCallbackdeps invalidate. Minor.
Nits
-
gsapDragCommit.ts:225—gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }). GSAP accepts arrays too;join(",")is fine but the explicit string conversion is one detail layer deep. Either works. -
gsapDragCommit.test.ts:48— selection element is a partial mock withgetPropertyValue: () => "". Reads cleanly butgetAttribute: () => nullpaired withremoveAttribute: () => {}reads as "we know exactly what's read" — that's brittle ifrestoreOffsetis extended. Not a real problem at this size. -
Debug-
console.logremoval ingsapRuntimeBridge.ts+useGsapAwareEditing.tsis clean — verified no logger replacement intended; this is pure cleanup.
Questions
-
The
coalesceKey = gsap:convert-drag:${anim.id}incommitFlatViaKeyframeschains the convert + extend on the same key. Does coalescing depend on the twocommitMutationcalls reaching the server in order? With the safe-wrapper fix above, the facade is no longer in this path (drag uses rawgsapCommitMutation), so order is preserved. But worth confirming with the source-mutation API in #1554/#1555 thatcoalesceKeyis reorder-safe vs ordered. -
Why does
tryGsapDragInterceptguard withreadRuntimeKeyframes(iframe, selector)but not also re-checkreadGsapPositionFromIframefor staleness? If the gsap-property read succeeds for a deleted element (e.g.xcached on the DOM element from the dead tween's last tick), the guard could pass but commit a phantom. Probably impossible — element-levelxis on the GSAP timeline, deleted with the tween — but worth a one-line comment confirming.
What I didn't verify
- The source-mutation API end of
replace-with-keyframesvsadd-with-keyframessemantics — covered by #1554 review. - Live drag UX under failure (commit rejects mid-chain) — manual QA needed.
- Cross-stack: does
parkPlayheadOnKeyframe'srequestSeekinteract with #1605's instant-flicker-free editing on the same flow? Both touch playhead.
Blocker on the missing await regression. Once that's resolved (option a — return the Promise from useSafeGsapCommitMutation and await it — is the cleanest), this PR is in good shape. Not stamp-routable until blocker is addressed.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at 57f4cd85. Concur with @james-russo-rames-d-jusso's BLOCKER on the commitMutation facade regression. Independently verified at HEAD and the diagnosis is correct. Adding one critical scope expansion: Rames cites useEnableKeyframes.ts:153 as the awaiting consumer — there's a second consumer the diagnosis missed, and it's the canonical HF #1558 failure shape we collectively fixed two days ago.
Verified the regression (concur with Rames's diagnosis):
packages/studio/src/hooks/useGsapAwareEditing.ts:240-246at57f4cd85: facade now callssafeGsapCommit(domEditSelection, mutation, options)WITHOUTawait. Pre-PR wasawait gsapCommitMutation?.(...).useSafeGsapCommitMutation.ts:46: returns a callback whose body isvoid commitMutation(...).catch(...)— thevoidoperator discards the promise, sosafeGsapCommit(...)returnsundefined.- Net: the outer
async commitMutationresolves on the next microtask BEFORE any save completes. The async type signature is now a lie. - The bridge layer (
tryGsapDragIntercept/tryGsapResizeIntercept/tryGsapRotationIntercept) uses the rawgsapCommitMutationparameter, NOT the facade — so drag/resize/rotation are still correctly awaitable.
Scope expansion (net-new):
Rames cites useEnableKeyframes.ts:153 as the affected awaiter via session.commitMutation (through DomEditContext.tsx). Verified at c3ec03a3 there's a second, more user-visible consumer:
packages/studio/src/hooks/useGestureCommit.ts:127, 172, 187, 200 (at c3ec03a3) — all four await liveSession.commitMutation(...) callsites, where liveSession = domEditSessionRef.current. After the await resolves:
- Line 93 →
showToast(\Recorded ${sortedPcts.length} keyframes`, "info")` - Line 98 →
store.requestSeek(recordingStartTimeRef.current) - Line 100 →
setGestureState("idle")
This is the canonical HF #1558 shape — the exact bug Rames + @terencecho converged on two days ago: success toast fires before save lands; on save failure, an error toast appears at a timestamp uncorrelated with the user's gesture; gesture-state flips to idle while the disk write may still be in flight. The fix Rames suggests (option (a) — make useSafeGsapCommitMutation return the inner Promise and await it in the facade) cleanly closes both consumer paths. Strong concur.
Concur with @james-russo-rames-d-jusso on the remaining concerns:
gsapRuntimeBridge.ts:230stale-parse guard accepts ANY non-nullreadRuntimeKeyframesread — verify the read layer doesn't return null forfrom()/fromTo()/to({motionPath, ease})shapes where vars carries no flat numeric prop. Edge-case unit test would pin it.gsapDragCommit.ts:218clearPropsbefore seek — concern about mid-drag-of-second-axis losing dragged values if the commit fails. Failure-path observability rubric applies; worth a fail-path test.useGsapScriptCommits.ts:85-89applied: "soft" | "full"dead local — either remove or wire to telemetry. The latter is a natural observability hook ("how often does soft fall to full") that I'd actively want.- Coalesce-key collision check (separate from Rames): verified disjoint at HEAD —
gsap:convert-drag:${anim.id}(3 sites ingsapDragCommit.ts),gsap:resize:${anim.id}(gsapRuntimeBridge.ts:324), rotation has no key. Prefixes prevent cross-path collision. Clean. manualEditsDom.ts:253-291applyStudioPathOffsetViaGsapextraction: this is anti-band-aid (clean dedup + the commit path now mirrors the draft path, fixing the GSAP-element off-canvas fling). The comment explaining symmetry is exactly the kind of in-code rationale future readers need. Good.
Other independently verified at HEAD:
- Backfill-defaults dispatch chain across
extendTweenAndAddKeyframe/ inside-range drag / outside-range drag / rotation / resize bridge — every keyframed branch backfills consistently. Dispatch-chain audit clean. - Console.log removal: zero
[drag:-prefixed log lines remain. Six removals, no replacements. Clean.
Verdict: BLOCK. Facade regression is the hard request-changes (Rames's fix option (a) is the cleanest); rest is comment-level. Once the facade returns the awaited promise, gesture recording's HF #1558 shape closes structurally and useEnableKeyframes.ts:153's consumer becomes truthful again.
Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Layering on @via's review for the facade-await blocker — second consumer worth flagging together:
useGestureCommit.ts:127/172/187/200 does await liveSession.commitMutation(...) then showToast("Recorded N keyframes") + requestSeek + setGestureState("idle") — canonical HF#1558 shape. Same root cause as the useEnableKeyframes.ts:153 consumer I cited at R1: the facade's fire-and-forget void useSafeGsapCommitMutation(...).catch() resolves the await before server save lands. This is the path that makes gesture-recording user-visibly broken (toast fires before save, seek lands on stale runtime, state machine returns to idle pre-persist).
Recommended fix (same one mechanism, two consumers): useSafeGsapCommitMutation returns the Promise (still .catch for toast/telemetry); facade awaits it. Closes both useEnableKeyframes and useGestureCommit consumers in one change.
Upgrades my R1 framing: this is a blocker for #1567 specifically (since #1567 lights up gesture recording), not just an abstract stack concern.
— Rames D Jusso
…e-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element.
f87ce08 to
e28b305
Compare
57f4cd8 to
c66e82a
Compare

Summary
This PR lands the GSAP-aware drag/commit/bridge editing infrastructure for Studio: dragging a GSAP-animated element on the canvas now routes through GSAP's own
x/yrather than colliding with a CSStranslate, the keyframe-commit logic correctly extends/modifies tweens instead of spawning parallel ones, and the runtime bridge refuses to resurrect just-deleted tweens from a stale server parse. It also makes the drag commit path go through the canonical safe-commit wrapper so save failures surface a toast instead of silently reverting. Debugconsole.logscaffolding from the bring-up phase is removed and the behavior is locked down with three new test suites.What's changed
packages/studio/src/components/editor/manualEditsDom.tsapplyStudioPathOffsetDraftinto a sharedapplyStudioPathOffsetViaGsap(element, offset)helper. It detects a GSAP-animated element (gsapAnimatesProperty(element, "x", "y")), forcestranslate: none, and pushes the drag delta into GSAP'sx/yviagsap.set(seeding/readingdata-hf-drag-gsap-base-x/yand subtracting the recorded initial offset). Returnstruewhen it handled the element so the caller skips the CSS path.applyStudioPathOffset(the commit path) now callsapplyStudioPathOffsetViaGsapand early-returns for GSAP elements. Previously only the draft path had this branch; the commit silently folded the offset into a CSStranslatethat composed on top of GSAP's matrix and flung the element off-canvas. Draft and commit are now symmetrical.packages/studio/src/hooks/gsapDragCommit.tsparkPlayheadOnKeyframe(anim, pct): after editing a selected keyframe, seeks the playhead to that keyframe's absolute time (tweenStart + pct/100 * tweenDuration, rounded) so the edit previews at the keyframe instead of the element's base pose.backfillDefaultsparameter threaded throughextendTweenAndAddKeyframe,commitKeyframedPosition,commitFlatViaKeyframes, and thefrom/fromTobranches. When a drag introduces a property the tween didn't animate (e.g.yon an x-only tween), the other keyframes are backfilled at the element's base value so GSAP doesn't hold the new prop's value across keyframes that omit it.commitGsapPositionFromDragderivesbackfillDefaults = { x: baseGsapX, y: baseGsapY }.activeKeyframePct != null(a clicked diamond), the commit modifies that keyframe and never extends/splits, even if the playhead has drifted a frame past the tween. After committing it clearsactiveKeyframePctand parks the playhead.commitFlatViaKeyframesoutside-range behavior changed from spawning a paralleladd-with-keyframestween to aconvert-to-keyframes+extendTweenAndAddKeyframeflow, so the element ends up with one tween instead of two competing ones. It also clears the live drag's GSAP overrides before seeking to read clean interpolated values for the0%keyframe.from/fromToextend branch now emitsreplace-with-keyframes(reusing the existing position tween's id) instead ofadd-with-keyframeswhen a position tween already exists, eliminating the post-drop "jump" caused by two position tweens fighting on the shared axis.packages/studio/src/hooks/gsapRuntimeBridge.tstryGsapDragIntercept: after resolving a candidate position tween from the cache/fetch fallback (an async server parse that lags a delete-all), it consults the live runtime viareadRuntimeKeyframes(iframe, selector). If the live timeline has no non-hold tween for the element, the parse is stale and it bails (return false→ CSS fallback) rather than re-committing a phantom tween and resurrecting a just-deleted animation.[drag:4]debugconsole.logcalls.packages/studio/src/hooks/useGsapAwareEditing.tscommitMutationfacade now routes throughuseSafeGsapCommitMutation(withuseGsapSaveFailureTelemetryandshowToast). This gives the drag path parity with the arc/keyframe/animation ops: a server-save failure surfaces a toast + save telemetry instead of silently reverting. Removed the[drag:3]debug log.packages/studio/src/hooks/useGsapScriptCommits.tsapplied: "soft" | "full") and falls back to a fullreloadPreview()whenapplySoftReloadreturnsfalse, making the soft-vs-full-reload decision explicit.Why
Dragging GSAP-animated elements was broken in several distinct ways: an off-canvas fling (the commit folded the offset into a CSS
translatewhile GSAP ownsstyle.transform); parallel/competing tweens that snapped on the soft-reload reseek (a visible jump after dropping); cross-keyframe drift when introducing a new property; a snap-away preview when a selected keyframe edit left the playhead just outside the tween; and resurrection of a deleted tween because the server parse lags a delete-all. This PR fixes all of these at the root, removes the debug logging used to diagnose them, and makes save failures observable.Testing
bun run test(studio). Three new suites:manualEditsDomGsap.test.ts(jsdom) — non-GSAP element folds the offset into a CSStranslate var()+ writes--hf-studio-offset-x/y; GSAP element keepstranslate: noneand routes viagsap.set; draft and commit treat a GSAP element identically (the regression guard for the old asymmetry).gsapDragCommit.test.ts— flat tween dragged outside range emitsconvert-to-keyframes+replace-with-keyframesand neveradd-with-keyframes; inside range emitsadd-keyframe; a selected keyframe past the end is modified (not split),activeKeyframePctcleared, playhead parked; keyframed-tween backfill passesbackfillDefaults;from()outside range emitssplit-into-property-groups+replace-with-keyframes;parkPlayheadOnKeyframeseeks to absolute time for 0%/100%/50%.gsapRuntimeBridge.test.ts— empty runtime + stale cached anim → intercept returnsfalse,commitMutationnever called (no resurrection); tween still live → guard doesn't trip.Stack
Part of the GSAP keyframe/motion-path stack, split into reviewable PRs:
#1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. This PR (#1608) sits on #1607 (read layer). Each PR builds independently; the combined diff is byte-identical to the originally-reviewed work.