From dcc46d51002879576886c687c139b801d473f2ac Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 14:30:47 -0700 Subject: [PATCH] fix(studio,core): resolve SDK-cutover review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 18 +-- .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 + .../studio/src/hooks/useDomEditCommits.ts | 27 +++- .../studio/src/hooks/useDomEditSession.ts | 28 +++- .../src/hooks/useElementLifecycleOps.ts | 13 ++ .../studio/src/hooks/useGsapAnimationOps.ts | 90 ++--------- .../studio/src/hooks/useGsapKeyframeOps.ts | 89 ++++------ .../src/hooks/useGsapPropertyDebounce.ts | 152 +++++------------- .../studio/src/hooks/useGsapScriptCommits.ts | 63 ++++++-- packages/studio/src/hooks/useSdkSession.ts | 27 ++-- .../studio/src/hooks/useTimelineEditing.ts | 55 +++++-- packages/studio/src/utils/gsapSoftReload.ts | 14 ++ packages/studio/src/utils/sdkCutover.test.ts | 16 +- packages/studio/src/utils/sdkCutover.ts | 90 ++++++++--- 14 files changed, 348 insertions(+), 336 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 72d749b112..01b8cd8940 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -190,6 +190,7 @@ export function StudioApp() { uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, sdkSession, + forceReloadSdkSession, }); const { activeBlockParams, @@ -263,14 +264,10 @@ export function StudioApp() { ? () => handleToggleRecordingRef.current() : undefined, }); - const selectSidebarTabStable = useCallback( - (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), - [], - ); - const getSidebarTabStable = useCallback( - () => leftSidebarRef.current?.getTab() ?? "compositions", - [], - ); + const sidebarTabRef = useRef({ + select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t), + get: () => leftSidebarRef.current?.getTab() ?? "compositions", + }); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -303,9 +300,10 @@ export function StudioApp() { reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, - selectSidebarTab: selectSidebarTabStable, - getSidebarTab: getSidebarTabStable, + selectSidebarTab: sidebarTabRef.current.select, + getSidebarTab: sidebarTabRef.current.get, sdkSession, + forceReloadSdkSession, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 24a663cb08..fb8d9b7ded 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -59,4 +59,6 @@ export interface GsapScriptCommitsParams { /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ sdkSession?: Composition | null; writeProjectFile?: (path: string, content: string) => Promise; + /** Resync the in-memory SDK session after a server-authoritative write. */ + forceReloadSdkSession?: () => void; } diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 64ad930423..583ee3507a 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -76,14 +76,17 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; - /** Stage 7 Step 3b: called after a successful server-side element patch. */ - onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; + /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK + * path, whose session is already current) so a later SDK edit doesn't + * serialize the pre-write doc and revert the server's change. */ + forceReloadSdkSession?: () => void; /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ onTrySdkPersist?: ( selection: DomEditSelection, operations: PatchOperation[], originalContent: string, targetPath: string, + options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean }, ) => Promise; /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; @@ -107,7 +110,7 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted, + forceReloadSdkSession, onTrySdkPersist, onTrySdkDelete, }: UseDomEditCommitsParams) { @@ -157,11 +160,20 @@ export function useDomEditCommits({ throw new Error(`Missing file contents for ${targetPath}`); } if (options?.shouldSave && !options.shouldSave()) return; + // Skip the SDK path when prepareContent is set (e.g. @font-face injection + // for a custom font): sdkCutoverPersist serializes only the patched DOM + // and would drop the injected content. Let the server path run prepareContent. if ( onTrySdkPersist && - (await onTrySdkPersist(selection, operations, originalContent, targetPath)) + !options?.prepareContent && + (await onTrySdkPersist(selection, operations, originalContent, targetPath, { + label: options?.label, + coalesceKey: options?.coalesceKey, + skipRefresh: options?.skipRefresh, + })) ) { - onDomEditPersisted?.(selection, operations); + // SDK handled it — its in-memory doc is already current, so do NOT + // forceReload (that would echo-reload the session we just wrote). return; } const patchTarget = buildDomEditPatchTarget(selection); @@ -234,7 +246,7 @@ export function useDomEditCommits({ coalesceKey: options?.coalesceKey, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); - onDomEditPersisted?.(selection, operations); + forceReloadSdkSession?.(); if (!options?.skipRefresh) { reloadPreview(); @@ -248,7 +260,7 @@ export function useDomEditCommits({ domEditSaveTimestampRef, reloadPreview, showToast, - onDomEditPersisted, + forceReloadSdkSession, onTrySdkPersist, ], ); @@ -310,6 +322,7 @@ export function useDomEditCommits({ reloadPreview, clearDomSelection, onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, }); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 16bdfd5e10..17ab699988 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -61,6 +61,7 @@ export interface UseDomEditSessionParams { selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; sdkSession?: Composition | null; + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -100,6 +101,7 @@ export function useDomEditSession({ selectSidebarTab, getSidebarTab, sdkSession, + forceReloadSdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -195,6 +197,7 @@ export function useDomEditSession({ showToast, sdkSession, writeProjectFile, + forceReloadSdkSession, }); // ── DOM commit handlers ── @@ -233,14 +236,24 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + forceReloadSdkSession, onTrySdkPersist: sdkSession - ? (selection, operations, originalContent, targetPath) => - sdkCutoverPersist(selection, operations, originalContent, targetPath, sdkSession, { - editHistory, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - }) + ? (selection, operations, originalContent, targetPath, options) => + sdkCutoverPersist( + selection, + operations, + originalContent, + targetPath, + sdkSession, + { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + options, + ) : undefined, onTrySdkDelete: sdkSession ? (hfId, originalContent, targetPath) => @@ -249,6 +262,7 @@ export function useDomEditSession({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, + compositionPath: activeCompPath, }) : undefined, }); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 27397ec8d1..7b78ce3194 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams { clearDomSelection: () => void; /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; + /** Resync the SDK session after a server-fallback delete. */ + forceReloadSdkSession?: () => void; commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], @@ -45,6 +47,7 @@ export function useElementLifecycleOps({ reloadPreview, clearDomSelection, onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, }: UseElementLifecycleOpsParams) { // fallow-ignore-next-line complexity @@ -103,6 +106,12 @@ export function useElementLifecycleOps({ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; const patchedContent = typeof removeData.content === "string" ? removeData.content : originalContent; + // ponytail: the server remove-element route (removeElementFromHtml) strips + // only the element node — it does NOT cascade-remove GSAP tweens targeting + // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This + // fallback runs only when the element isn't in the SDK doc (e.g. runtime- + // generated / unaddressable), where targeting tweens are unlikely. Upgrade + // path: cascade in removeElementFromHtml by selector/hf-id to fully match. await saveProjectFilesWithHistory({ projectId: pid, label: "Delete element", @@ -115,6 +124,9 @@ export function useElementLifecycleOps({ clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); + // Server wrote the file; resync the stale in-memory SDK doc so a later + // SDK edit doesn't resurrect the deleted element. + forceReloadSdkSession?.(); reloadPreview(); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { @@ -128,6 +140,7 @@ export function useElementLifecycleOps({ domEditSaveTimestampRef, editHistory.recordEdit, onTrySdkDelete, + forceReloadSdkSession, projectIdRef, reloadPreview, showToast, diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 07898a3230..66cec59fb7 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -2,27 +2,16 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { sdkGsapTweenPersist } from "../utils/sdkCutover"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; interface SdkAnimationDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; } interface GsapAnimationOpsParams extends SdkAnimationDeps { @@ -40,10 +29,7 @@ export function useGsapAnimationOps({ commitMutationSafely, showToast, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( async ( @@ -51,19 +37,13 @@ export function useGsapAnimationOps({ animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: updates }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); if (handled) return; @@ -74,32 +54,18 @@ export function useGsapAnimationOps({ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); }, - [ - commitMutationSafely, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteGsapAnimation = useCallback( async (selection: DomEditSelection, animationId: string) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "remove", animationId }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: "Delete GSAP animation" }, ); if (handled) return; @@ -110,15 +76,7 @@ export function useGsapAnimationOps({ { label: "Delete GSAP animation" }, ); }, - [ - commitMutationSafely, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteAllForSelector = useCallback( @@ -168,16 +126,12 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // SDK path: addGsapTween only supports from/to/fromTo; "set" stays server-side - if ( - method !== "set" && - selection.hfId && - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + // SDK path: addGsapTween only supports from/to/fromTo; "set" stays + // server-side. Skip the SDK path when an id was just assigned server-side + // (autoId): the SDK session hasn't reloaded that write yet, so persisting + // its serialization would clobber the new id — let the server add the + // tween atomically with the id it wrote. + if (!autoId && method !== "set" && selection.hfId && sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const spec = { method: method as "to" | "from" | "fromTo", @@ -191,7 +145,7 @@ export function useGsapAnimationOps({ targetPath, { kind: "add", target: selection.hfId, spec }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP ${method} animation` }, ); if (handled) return; @@ -212,17 +166,7 @@ export function useGsapAnimationOps({ { label: `Add GSAP ${method} animation` }, ); }, - [ - activeCompPath, - commitMutation, - projectIdRef, - showToast, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps], ); return { diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index b7179cd862..6f550fbfba 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -3,7 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; -import { sdkGsapKeyframePersist } from "../utils/sdkCutover"; +import { sdkGsapKeyframePersist, type CutoverDeps } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -12,7 +12,6 @@ import type { SafeGsapCommitMutation, TrackGsapSaveFailure, } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; function executeOptimisticKeyframeCacheUpdate(options: { sourceFile: string; @@ -35,17 +34,7 @@ function executeOptimisticKeyframeCacheUpdate(options: { interface SdkKeyframeDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; } interface GsapKeyframeOpsParams extends SdkKeyframeDeps { @@ -61,10 +50,7 @@ export function useGsapKeyframeOps({ commitMutationSafely, trackGsapSaveFailure, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }: GsapKeyframeOpsParams) { const addKeyframe = useCallback( ( @@ -84,27 +70,37 @@ export function useGsapKeyframeOps({ void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, - apply: (prev) => ({ - ...prev, - keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( - (a, b) => a.percentage - b.percentage, - ), - }), + // Merge into an existing keyframe at this percentage rather than + // appending a duplicate — matches addKeyframeToScript, which writes one + // keyframe per percentage (merging properties). + apply: (prev) => { + const idx = prev.keyframes.findIndex( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001, + ); + if (idx >= 0) { + const keyframes = prev.keyframes.slice(); + keyframes[idx] = { + ...keyframes[idx], + properties: { ...keyframes[idx].properties, [property]: value }, + }; + return { ...prev, keyframes }; + } + return { + ...prev, + keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( + (a, b) => a.percentage - b.percentage, + ), + }; + }, persist: async () => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const handled = await sdkGsapKeyframePersist( sourceFile, animationId, percentage, { [property]: value }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add keyframe at ${percentage}%`, coalesceKey: `gsap:${animationId}:kf:${percentage}`, @@ -121,16 +117,7 @@ export function useGsapKeyframeOps({ trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); }); }, - [ - activeCompPath, - commitMutation, - trackGsapSaveFailure, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const addKeyframeBatch = useCallback( @@ -140,13 +127,7 @@ export function useGsapKeyframeOps({ percentage: number, properties: Record, ) => { - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + if (sdkSession && sdkDeps) { const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapKeyframePersist( sourceFile, @@ -154,7 +135,7 @@ export function useGsapKeyframeOps({ percentage, properties, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add keyframe at ${percentage}%` }, ); if (handled) return; @@ -165,15 +146,7 @@ export function useGsapKeyframeOps({ { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, - [ - commitMutation, - activeCompPath, - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - ], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeKeyframe = useCallback( diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 103b9892d6..218397b654 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,32 +1,21 @@ import { useCallback, useEffect, useRef } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { sdkGsapTweenPersist } from "../utils/sdkCutover"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; const DEBOUNCE_MS = 150; interface SdkPropertyDeps { sdkSession?: Composition | null; - writeProjectFile?: (path: string, content: string) => Promise; - editHistory?: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - reloadPreview?: () => void; - domEditSaveTimestampRef?: React.MutableRefObject; + sdkDeps?: CutoverDeps | null; activeCompPath?: string | null; } export function useGsapPropertyDebounce( commitMutationSafely: SafeGsapCommitMutation, - sdkDeps?: SdkPropertyDeps, + sdk?: SdkPropertyDeps, ) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; @@ -36,51 +25,33 @@ export function useGsapPropertyDebounce( } | null>(null); const debounceTimerRef = useRef | null>(null); - // fallow-ignore-next-line complexity - const flushPendingPropertyEdit = useCallback( - // fallow-ignore-next-line complexity - async () => { - const pending = pendingPropertyEditRef.current; - if (!pending) return; - pendingPropertyEditRef.current = null; - const { selection, animationId, property, value } = pending; - const { + const flushPendingPropertyEdit = useCallback(async () => { + const pending = pendingPropertyEditRef.current; + if (!pending) return; + pendingPropertyEditRef.current = null; + const { selection, animationId, property, value } = pending; + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: value } } }, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const handled = await sdkGsapTweenPersist( - targetPath, - { kind: "set", animationId, properties: { properties: { [property]: value } } }, - sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, - { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, - ); - if (handled) return; - } - commitMutationSafely( - selection, - { type: "update-property", animationId, property, value }, - { - label: `Edit GSAP ${property}`, - coalesceKey: `gsap:${animationId}:${property}`, - softReload: true, - }, + sdkDeps, + { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, ); - }, - [commitMutationSafely, sdkDeps], - ); + if (handled) return; + } + commitMutationSafely( + selection, + { type: "update-property", animationId, property, value }, + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + softReload: true, + }, + ); + }, [commitMutationSafely, sdk]); const updateGsapProperty = useCallback( ( @@ -118,27 +89,14 @@ export function useGsapPropertyDebounce( const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP ${property}` }, ); if (handled) return; @@ -149,7 +107,7 @@ export function useGsapPropertyDebounce( { label: `Add GSAP ${property}` }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); const removeGsapProperty = useCallback( @@ -164,36 +122,21 @@ export function useGsapPropertyDebounce( [commitMutationSafely], ); - // fallow-ignore-next-line complexity const updateGsapFromProperty = useCallback( - // fallow-ignore-next-line complexity async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, { kind: "set", animationId, properties: { fromProperties: { [property]: value } } }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Edit GSAP from-${property}`, coalesceKey: `gsap:${animationId}:from:${property}`, @@ -210,29 +153,14 @@ export function useGsapPropertyDebounce( }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); - // fallow-ignore-next-line complexity const addGsapFromProperty = useCallback( - // fallow-ignore-next-line complexity async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; - const { - sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, - activeCompPath, - } = sdkDeps ?? {}; - if ( - sdkSession && - writeProjectFile && - editHistory && - reloadPreview && - domEditSaveTimestampRef - ) { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { const targetPath = selection.sourceFile || activeCompPath || "index.html"; const handled = await sdkGsapTweenPersist( targetPath, @@ -242,7 +170,7 @@ export function useGsapPropertyDebounce( properties: { fromProperties: { [property]: defaultValue } }, }, sdkSession, - { editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + sdkDeps, { label: `Add GSAP from-${property}` }, ); if (handled) return; @@ -253,7 +181,7 @@ export function useGsapPropertyDebounce( { label: `Add GSAP from-${property}` }, ); }, - [commitMutationSafely, sdkDeps], + [commitMutationSafely, sdk], ); const removeGsapFromProperty = useCallback( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index b1796b365c..8f011f01e7 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,7 +1,8 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { applySoftReload } from "../utils/gsapSoftReload"; +import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; +import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, @@ -43,7 +44,8 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { + // fallow-ignore-next-line complexity const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; if (!pid) return; @@ -68,6 +70,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } if (result.after != null) onFileContentChanged?.(targetPath, result.after); + // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a + // later SDK-routed edit doesn't serialize the pre-write doc and revert this. + forceReloadSdkSession?.(); if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); @@ -77,15 +82,47 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); + + // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the + // hooks' callbacks keep a stable identity (an inline literal here re-fired the + // property-debounce flush on every render). refresh() soft-reloads (preserving + // the playhead) and invalidates the panel cache, matching the server path. + const sdkRefresh = useCallback( + (after: string) => { + const script = extractGsapScriptText(after); + if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview(); + onCacheInvalidate(); + }, + [previewIframeRef, reloadPreview, onCacheInvalidate], + ); + const sdkDeps = useMemo( + () => + writeProjectFile + ? { + editHistory: { recordEdit: editHistory.recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + refresh: sdkRefresh, + compositionPath: activeCompPath, + } + : null, + [ + editHistory.recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + sdkRefresh, + activeCompPath, + ], + ); + const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, activeCompPath, }); const animationOps = useGsapAnimationOps({ @@ -95,10 +132,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra commitMutationSafely, showToast, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }); const keyframeOps = useGsapKeyframeOps({ activeCompPath, @@ -106,10 +140,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra commitMutationSafely, trackGsapSaveFailure, sdkSession, - writeProjectFile, - editHistory, - reloadPreview, - domEditSaveTimestampRef, + sdkDeps, }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 9bfd64f71e..2c0e205011 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -21,12 +21,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. The persist queue writes back to `activeCompPath` (not the - * "composition.html" default). - * - * The session is idle until Step 3c routes dispatch ops through it; re-opening - * is therefore purely additive — no SDK self-write exists yet, so there is no - * persist echo. Step 3c must add self-write suppression once dispatch writes. + * stale. The session has NO persist queue — Studio is the sole file writer; see + * the open effect below. */ // Time-window heuristic: suppress file-change reloads for 2 s after our own // SDK cutover write, to avoid an echo-reload on the write we just committed. @@ -95,13 +91,13 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - const comp = await openComposition(content, { - persist: adapter, - persistPath: activeCompPath, - }); - comp.on("persist:error", (e) => { - console.warn("[sdk] persist:error", e.error); - }); + // No persist queue: Studio's writeProjectFile (via sdkCutover's + // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist + // queue too would double-write the file (queue auto-writes on every + // 'change' AND Studio writes explicitly) and race on disk; it would + // also write the full active-composition serialization to the fixed + // persistPath even when an edit targeted a sub-composition file. + const comp = await openComposition(content); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); @@ -116,8 +112,9 @@ export function useSdkSession( return () => { cancelled = true; - const c = compRef.current; - if (c) void c.flush().finally(() => c.dispose()); + // No queue to flush; dispose only. (Flushing here would serialize the + // pre-undo in-memory doc and race the revert write on undo/redo reload.) + compRef.current?.dispose(); }; }, [projectId, activeCompPath, reloadToken]); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index c3f890cc7a..56e0e48175 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -55,6 +55,8 @@ interface UseTimelineEditingOptions { isRecordingRef?: React.RefObject; /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ sdkSession?: Composition | null; + /** Resync the SDK session after a server-authoritative timeline write. */ + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -73,6 +75,7 @@ export function useTimelineEditing({ uploadProjectFiles, isRecordingRef, sdkSession, + forceReloadSdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -92,19 +95,24 @@ export function useTimelineEditing({ } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); - const queued = editQueueRef.current.then(() => - persistTimelineEdit({ - projectId: pid, - element, - activeCompPath, - label, - buildPatches, - writeProjectFile, - recordEdit, - domEditSaveTimestampRef, - pendingTimelineEditPathRef, - }), - ); + const queued = editQueueRef.current + .then(() => + persistTimelineEdit({ + projectId: pid, + element, + activeCompPath, + label, + buildPatches, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + }), + ) + .then(() => { + // Server wrote the file; resync the stale in-memory SDK doc. + forceReloadSdkSession?.(); + }); editQueueRef.current = queued.catch((error) => { console.error(`[Timeline] Failed to persist: ${label}`, error); }); @@ -118,6 +126,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, showToast, isRecordingRef, + forceReloadSdkSession, ], ); @@ -148,7 +157,13 @@ export function useTimelineEditing({ targetPath, { start: updates.start, trackIndex: updates.track }, sdkSession, - { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, { label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` }, ).then((handled) => { if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches); @@ -212,7 +227,13 @@ export function useTimelineEditing({ targetPath, { start: updates.start, duration: updates.duration }, sdkSession, - { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, { label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` }, ).then((handled) => { if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches); @@ -289,6 +310,7 @@ export function useTimelineEditing({ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)), ); usePlayerStore.getState().setSelectedElementId(null); + forceReloadSdkSession?.(); reloadPreview(); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { @@ -305,6 +327,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); @@ -373,6 +396,7 @@ export function useTimelineEditing({ recordEdit, }); + forceReloadSdkSession?.(); reloadPreview(); } catch (error) { const message = @@ -389,6 +413,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 584658a765..e60e001d86 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] { return results; } +/** + * Extract the GSAP timeline script text from a serialized HTML document, for + * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts + * are present (ambiguous — caller should fall back to a full reload), matching + * applySoftReload's own single-script requirement. + */ +export function extractGsapScriptText(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const scripts = findGsapScriptElements(doc); + if (scripts.length !== 1) return null; + return scripts[0].textContent || null; +} + /** Check that the new script repopulated __timelines with at least one entry. */ function verifyTimelinesPopulated(win: IframeWindow): boolean { const tlKeys = win.__timelines @@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; + // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; const allTargets: Element[] = []; diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 9f4d321b79..737493c3f6 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -97,7 +97,12 @@ describe("sdkCutoverPersist", () => { ({ getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), dispatch: vi.fn(), - serialize: vi.fn().mockReturnValue(""), + // Distinct before/after so the no-op guard (after === before → fall back) + // treats this as a real change; "after" matches the write assertions. + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue(""), batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[4]; @@ -311,7 +316,11 @@ describe("sdkDeletePersist", () => { ({ getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null), removeElement: vi.fn(), - serialize: vi.fn().mockReturnValue("after"), + serialize: vi + .fn() + .mockReturnValueOnce("before-snap") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[3]; it("returns false when session is null", async () => { @@ -383,6 +392,7 @@ describe("sdkTimingPersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[3]; it("returns false when session is null", async () => { @@ -459,6 +469,7 @@ describe("sdkGsapTweenPersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[2]; it("returns false when session is null", async () => { @@ -566,6 +577,7 @@ describe("sdkGsapKeyframePersist", () => { .fn() .mockReturnValueOnce("before") .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[4]; it("returns false when session is null", async () => { diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index f000e8085a..fdaecc9e9e 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -57,7 +57,7 @@ export function shouldUseSdkCutover( return hasSession && !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)); } -interface CutoverDeps { +export interface CutoverDeps { editHistory: { recordEdit: (entry: { label: string; @@ -69,22 +69,44 @@ interface CutoverDeps { writeProjectFile: (path: string, content: string) => Promise; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; + /** + * Optional post-write refresh. When provided, it REPLACES the default + * reloadPreview() — the GSAP path passes one that soft-reloads (preserving + * the playhead) and invalidates the keyframe/gsap panel cache. Receives the + * serialized document just written. + */ + refresh?: (after: string) => void; + /** + * Path of the composition the SDK session was opened for. The session models + * ONLY this file (serialize() emits the whole active composition), so any edit + * whose targetPath differs (a sub-composition file) must take the server path + * — otherwise we'd write the full active-comp serialization into that file. + */ + compositionPath?: string | null; +} + +/** True when targetPath isn't the composition the SDK session models. */ +function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { + return deps.compositionPath != null && targetPath !== deps.compositionPath; } interface CutoverOptions { label?: string; coalesceKey?: string; + /** Skip the preview reload (mirrors the server path's skipRefresh). */ + skipRefresh?: boolean; } -// ponytail: internal; export only if a third caller appears +// ponytail: internal; export only if a third caller appears. +// `after` is serialized once by the caller (which also did the no-op check +// against its pre-dispatch snapshot), so this never re-serializes. async function persistSdkSerialize( - sdkSession: Composition, + after: string, targetPath: string, originalContent: string, deps: CutoverDeps, options?: CutoverOptions, ): Promise { - const after = sdkSession.serialize(); deps.domEditSaveTimestampRef.current = Date.now(); await deps.writeProjectFile(targetPath, after); await deps.editHistory.recordEdit({ @@ -93,7 +115,8 @@ async function persistSdkSerialize( ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), files: { [targetPath]: { before: originalContent, after } }, }); - deps.reloadPreview(); + if (deps.refresh) deps.refresh(after); + else if (!options?.skipRefresh) deps.reloadPreview(); } export async function sdkCutoverPersist( @@ -110,13 +133,17 @@ export async function sdkCutoverPersist( const hfId = selection.hfId; if (!hfId) return false; if (!sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { + const before = sdkSession.serialize(); sdkSession.batch(() => { for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { sdkSession.dispatch(editOp); } }); - await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); return true; } catch (err) { @@ -137,10 +164,13 @@ export async function sdkTimingPersist( options?: CutoverOptions, ): Promise { if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); - sdkSession.setTiming(hfId, timingUpdate); - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); return true; } catch (err) { @@ -162,17 +192,26 @@ export async function sdkGsapTweenPersist( options?: CutoverOptions, ): Promise { if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { + if (op.kind === "add" && !sdkSession.getElement(op.target)) return false; const before = sdkSession.serialize(); - if (op.kind === "add") { - if (!sdkSession.getElement(op.target)) return false; - sdkSession.addGsapTween(op.target, op.spec); - } else if (op.kind === "set") { - sdkSession.setGsapTween(op.animationId, op.properties); - } else { - sdkSession.removeGsapTween(op.animationId); - } - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => { + if (op.kind === "add") { + sdkSession.addGsapTween(op.target, op.spec); + } else if (op.kind === "set") { + sdkSession.setGsapTween(op.animationId, op.properties); + } else { + sdkSession.removeGsapTween(op.animationId); + } + }); + const after = sdkSession.serialize(); + // No-op (stale animationId, unsupported shape e.g. from-prop on a plain + // tween): fall back to the server path so it surfaces the proper error + // instead of writing a phantom before==after undo step. Subsumes a + // per-op existence guard for the set/remove branches. + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { opCount: 1 }); return true; } catch (err) { @@ -191,10 +230,15 @@ export async function sdkGsapKeyframePersist( options?: CutoverOptions, ): Promise { if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { const before = sdkSession.serialize(); - sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }); - await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + sdkSession.batch(() => + sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }), + ); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); trackStudioEvent("sdk_cutover_success", { opCount: 1 }); return true; } catch (err) { @@ -211,9 +255,13 @@ export async function sdkDeletePersist( deps: CutoverDeps, ): Promise { if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; try { - sdkSession.removeElement(hfId); - await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, { + const before = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.removeElement(hfId)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, { label: "Delete element", }); trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });