From c60841380d0e73aa3ef1e524969a35375ceb1a7a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 11:23:39 -0700 Subject: [PATCH] fix(studio): make sdk cutover transactional --- packages/studio/src/App.tsx | 3 + .../components/DesignPanelPromoteProvider.tsx | 1 + .../src/components/StudioRightPanel.tsx | 5 + .../src/components/panels/VariablesPanel.tsx | 3 + .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 + .../studio/src/hooks/useDomEditCommits.ts | 30 +- .../studio/src/hooks/useDomEditSession.ts | 7 + .../src/hooks/useElementLifecycleOps.ts | 11 +- .../studio/src/hooks/useGsapAnimationOps.ts | 13 +- .../studio/src/hooks/useGsapKeyframeOps.ts | 11 +- .../src/hooks/useGsapPropertyDebounce.ts | 71 ++-- .../useGsapPropertyDebounceFlush.test.ts | 35 ++ .../studio/src/hooks/useGsapScriptCommits.ts | 9 +- packages/studio/src/hooks/useSdkSession.ts | 31 +- .../studio/src/hooks/useSlideshowPersist.ts | 6 + .../studio/src/hooks/useTimelineEditing.ts | 26 +- .../src/hooks/useTimelineEditingTypes.ts | 2 + .../src/hooks/useTimelineGroupEditing.ts | 12 +- .../studio/src/hooks/useVariablesPersist.ts | 16 +- .../studio/src/utils/sdkCutover.gate.test.ts | 14 +- packages/studio/src/utils/sdkCutover.test.ts | 274 +++++++++++-- packages/studio/src/utils/sdkCutover.ts | 362 ++++++++---------- .../studio/src/utils/sdkEditTransaction.ts | 288 ++++++++++++++ 23 files changed, 894 insertions(+), 338 deletions(-) create mode 100644 packages/studio/src/utils/sdkEditTransaction.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4cb09db319..ef83585d1b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -176,6 +176,7 @@ export function StudioApp() { uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, sdkSession: editFlowSdkSession, + publishSdkSession: sdkHandle.publish, forceReloadSdkSession: sdkHandle.forceReload, handleDomZIndexReorderCommitRef, }); @@ -317,6 +318,7 @@ export function StudioApp() { selectSidebarTab: sidebarTabRef.current.select, getSidebarTab: sidebarTabRef.current.get, sdkSession: editFlowSdkSession, + publishSdkSession: sdkHandle.publish, forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; @@ -533,6 +535,7 @@ export function StudioApp() { recordingDuration={gestureRecording.recordingDuration} onToggleRecording={recordingToggle} sdkSession={sdkHandle.session} + publishSdkSession={sdkHandle.publish} reloadPreview={reloadPreview} domEditSaveTimestampRef={domEditSaveTimestampRef} recordEdit={editHistory.recordEdit} diff --git a/packages/studio/src/components/DesignPanelPromoteProvider.tsx b/packages/studio/src/components/DesignPanelPromoteProvider.tsx index 80b523955f..de0747fd7a 100644 --- a/packages/studio/src/components/DesignPanelPromoteProvider.tsx +++ b/packages/studio/src/components/DesignPanelPromoteProvider.tsx @@ -35,6 +35,7 @@ export function DesignPanelPromoteProvider({ const persist = useVariablesPersist({ ...persistDeps, sdkSession: handle.session, + publishSdkSession: handle.publish, activeCompPath: targetPath, }); return ( diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 652876c2bb..24cfd762bf 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -56,6 +56,7 @@ export interface StudioRightPanelProps { onToggleRecording?: () => void; /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ sdkSession: Composition | null; + publishSdkSession: (session: Composition) => void; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { @@ -75,6 +76,7 @@ export function StudioRightPanel({ recordingDuration, onToggleRecording, sdkSession, + publishSdkSession, reloadPreview, domEditSaveTimestampRef, recordEdit, @@ -167,6 +169,7 @@ export function StudioRightPanel({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, }); // Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures @@ -179,6 +182,7 @@ export function StudioRightPanel({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes", }); @@ -534,6 +538,7 @@ export function StudioRightPanel({ ) : rightPanelTab === "variables" ? ( void; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { @@ -247,6 +248,7 @@ const EMPTY_STATE = ( // fallow-ignore-next-line complexity export const VariablesPanel = memo(function VariablesPanel({ sdkSession, + publishSdkSession, reloadPreview, domEditSaveTimestampRef, recordEdit, @@ -285,6 +287,7 @@ export const VariablesPanel = memo(function VariablesPanel({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, }); const declarations = useMemo( diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 4de90da3d0..8fa9bb1b4a 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -87,6 +87,8 @@ export interface GsapScriptCommitsParams { showToast: (message: string, tone?: "error" | "info") => void; /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ sdkSession?: Composition | null; + /** Publish a fully persisted candidate SDK session. */ + publishSdkSession?: (session: Composition) => void; 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 349d1889ba..7d99cc9ce1 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -29,6 +29,7 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; import { readProjectFileContent } from "../utils/studioFileHistory"; +import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; // ── Helpers ── @@ -117,16 +118,20 @@ export interface UseDomEditCommitsParams { * 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. */ + /** Stage 7 Step 3c: called before the server-side patch path. */ 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; + ) => Promise; + /** Stage 7 §3.1: called before the server-side delete path. */ + onTrySdkDelete?: ( + hfId: string, + originalContent: string, + targetPath: string, + ) => Promise; /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */ onReorderShadow?: (targets: string[]) => void; } @@ -221,18 +226,17 @@ export function useDomEditCommits({ // 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 && - !options?.prepareContent && - (await onTrySdkPersist(selection, operations, originalContent, targetPath, { + if (onTrySdkPersist && !options?.prepareContent) { + const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, { label: options?.label, coalesceKey: options?.coalesceKey, skipRefresh: options?.skipRefresh, - })) - ) { - // SDK handled it — its in-memory doc is already current, so do NOT - // forceReload (that would echo-reload the session we just wrote). - return; + }); + if (cutoverCommittedOrThrow(cutover)) { + // SDK handled it — its in-memory doc is already current, so do NOT + // forceReload (that would echo-reload the session we just wrote). + return; + } } // Mark the save timestamp before the file write so the SSE file-change diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index f8cba7f987..130095bb1b 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -67,6 +67,7 @@ export interface UseDomEditSessionParams { selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; sdkSession?: Composition | null; + publishSdkSession?: (session: Composition) => void; forceReloadSdkSession?: () => void; } @@ -108,6 +109,7 @@ export function useDomEditSession({ selectSidebarTab, getSidebarTab, sdkSession, + publishSdkSession, forceReloadSdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; @@ -217,6 +219,7 @@ export function useDomEditSession({ onFileContentChanged: updateEditingFileContent, showToast, sdkSession, + publishSdkSession, writeProjectFile, forceReloadSdkSession, }); @@ -274,6 +277,8 @@ export function useDomEditSession({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, + readProjectFile, + publishSession: publishSdkSession, }, options, ); @@ -287,6 +292,8 @@ export function useDomEditSession({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, + readProjectFile, + publishSession: publishSdkSession, }) : undefined, // Resolver shadow for the z-index reorder edit: it takes the server path (no diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index eb5b8e077b..c77af782fe 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -12,10 +12,15 @@ import { type DomEditSelection, } from "../components/editor/domEditing"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; +import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { - /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ - onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; + /** Route delete through SDK when session resolves the hf-id. */ + onTrySdkDelete?: ( + hfId: string, + originalContent: string, + targetPath: string, + ) => Promise; /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */ onReorderShadow?: (targets: string[]) => void; /** Resync the SDK session after a server-fallback delete. */ @@ -59,7 +64,7 @@ export function useElementLifecycleOps({ if (onTrySdkDelete && selection.hfId) { const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath); - if (handled) { + if (cutoverCommittedOrThrow(handled)) { clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index 4eef046a87..260c5cac1c 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -7,6 +7,7 @@ import { sdkGsapDeleteAllForSelectorPersist, sdkAddWithKeyframesPersist, sdkReplaceWithKeyframesPersist, + cutoverCommittedOrThrow, type CutoverDeps, } from "../utils/sdkCutover"; import { @@ -52,7 +53,7 @@ export function useGsapAnimationOps({ sdkDeps, { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, @@ -74,7 +75,7 @@ export function useGsapAnimationOps({ sdkDeps, { label: "Delete GSAP animation" }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, @@ -96,7 +97,7 @@ export function useGsapAnimationOps({ sdkDeps, { label: "Delete all animations for element" }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } void commitMutation( selection, @@ -162,7 +163,7 @@ export function useGsapAnimationOps({ sdkDeps, { label: `Add GSAP ${method} animation` }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } await commitMutation( @@ -213,7 +214,7 @@ export function useGsapAnimationOps({ sdkDeps, { label }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } void commitMutation( selection, @@ -256,7 +257,7 @@ export function useGsapAnimationOps({ sdkDeps, { label }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } void commitMutation( selection, diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 63fa8ca0e4..fdaebc7551 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -8,6 +8,7 @@ import { sdkGsapRemoveKeyframePersist, sdkGsapRemoveAllKeyframesPersist, sdkGsapConvertToKeyframesPersist, + cutoverCommittedOrThrow, type CutoverDeps, } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; @@ -136,7 +137,7 @@ export function useGsapKeyframeOps({ coalesceKey: `gsap:${animationId}:kf:${percentage}`, }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } await commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, @@ -169,7 +170,7 @@ export function useGsapKeyframeOps({ sdkDeps, toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides), ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } return commitMutation( selection, @@ -218,7 +219,7 @@ export function useGsapKeyframeOps({ sdkDeps, toSdkPersistOptions(label, commitOverrides), ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } const commitOptions = commitOverrides?.skipReload ? { label, ...commitOverrides } @@ -300,7 +301,7 @@ export function useGsapKeyframeOps({ sdkDeps, toSdkPersistOptions("Convert to keyframes", commitOverrides), ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } return commitMutation( selection, @@ -329,7 +330,7 @@ export function useGsapKeyframeOps({ sdkDeps, { label: "Remove all keyframes" }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 3b59609ce3..a57e6c5954 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -5,6 +5,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { sdkGsapTweenPersist, sdkGsapRemovePropertyPersist, + cutoverCommittedOrThrow, type CutoverDeps, } from "../utils/sdkCutover"; import { extractGsapScriptText } from "../utils/gsapSoftReload"; @@ -45,6 +46,12 @@ interface SdkPropertyDeps { sdkSession?: Composition | null; sdkDeps?: CutoverDeps | null; activeCompPath?: string | null; + onFlushError?: ( + error: unknown, + selection: DomEditSelection, + mutation: Record, + label: string, + ) => void; } export function useGsapPropertyDebounce( @@ -68,38 +75,46 @@ export function useGsapPropertyDebounce( const sdkRef = useRef(sdk); sdkRef.current = sdk; + // fallow-ignore-next-line complexity const flushPendingPropertyEdit = useCallback(async () => { const pending = pendingPropertyEditRef.current; if (!pending) return; pendingPropertyEditRef.current = null; const { selection, animationId, property, value } = pending; - const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; - if (sdkSession && sdkDeps) { - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const handled = await sdkGsapTweenPersist( - targetPath, - { - kind: "set", - animationId, - properties: { - properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"), + const mutation = { type: "update-property", animationId, property, value }; + const label = `Edit GSAP ${property}`; + try { + const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { + properties: mergeTweenProperties( + sdkSession, + animationId, + { [property]: value }, + "to", + ), + }, }, - }, - sdkSession, - sdkDeps, - { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, - ); - if (handled) return; - } - commitMutationSafely( - selection, - { type: "update-property", animationId, property, value }, - { - label: `Edit GSAP ${property}`, + sdkSession, + sdkDeps, + { label, coalesceKey: `gsap:${animationId}:${property}` }, + ); + if (cutoverCommittedOrThrow(handled)) return; + } + await commitMutationSafely(selection, mutation, { + label, coalesceKey: `gsap:${animationId}:${property}`, softReload: true, - }, - ); + }); + } catch (error) { + sdkRef.current?.onFlushError?.(error, selection, mutation, label); + } }, [commitMutationSafely]); const updateGsapProperty = useCallback( @@ -162,7 +177,7 @@ export function useGsapPropertyDebounce( sdkDeps, { label: `Add GSAP ${property}` }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, @@ -187,7 +202,7 @@ export function useGsapPropertyDebounce( sdkDeps, { label: `Remove GSAP ${from ? `from-${property}` : property}` }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } if (from) { commitMutationSafely( @@ -247,7 +262,7 @@ export function useGsapPropertyDebounce( coalesceKey: `gsap:${animationId}:from:${property}`, }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, @@ -285,7 +300,7 @@ export function useGsapPropertyDebounce( sdkDeps, { label: `Add GSAP from-${property}` }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } commitMutationSafely( selection, diff --git a/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts index 7890f0d007..64dd929a66 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounceFlush.test.ts @@ -82,4 +82,39 @@ describe("useGsapPropertyDebounce flush stability (finding #7)", () => { root.unmount(); }); }); + + it("reports a rejected debounced flush instead of leaking an unhandled rejection", async () => { + const error = new Error("save failed"); + const commitMutationSafely = vi.fn().mockRejectedValue(error); + const onFlushError = vi.fn(); + let queueEdit: (() => void) | null = null; + + function Harness() { + const ops = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession: null, + sdkDeps: null, + activeCompPath: "index.html", + onFlushError, + }); + queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", 42); + return null; + } + + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Harness)); + }); + act(() => queueEdit?.()); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(onFlushError).toHaveBeenCalledWith( + error, + selection, + { type: "update-property", animationId: "tw-1", property: "x", value: 42 }, + "Edit GSAP x", + ); + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 2afe9f27cd..eb1feb652d 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -6,6 +6,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; import type { SoftReloadResult } from "../utils/gsapSoftReload"; import { trackStudioEvent } from "../utils/studioTelemetry"; +import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch"; @@ -208,7 +209,7 @@ export function applyPreviewSync( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for // the same animationId so their POSTs can't interleave. Held in a ref so the @@ -345,6 +346,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra compositionPath: activeCompPath, serialize: serializeByFile, readProjectFile: readProjectFileContent, + publishSession: publishSdkSession, } : null, [ @@ -356,6 +358,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra activeCompPath, serializeByFile, readProjectFileContent, + publishSdkSession, ], ); @@ -363,6 +366,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra sdkSession, sdkDeps, activeCompPath, + onFlushError: (error, selection, mutation, label) => { + trackGsapSaveFailure(error, selection, mutation, label); + showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error"); + }, }); const animationOps = useGsapAnimationOps({ projectIdRef, diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 9f3a79943a..89abeb954b 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk"; import { readStudioFileChangePath } from "../components/editor/manualEdits"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; +import { trackStudioEvent } from "../utils/studioTelemetry"; /** * Read a project file's content, or undefined on a non-2xx (optional read). @@ -82,6 +83,8 @@ export function shouldReloadOnFileChange( export interface SdkSessionHandle { session: Composition | null; + /** Atomically publish a fully persisted candidate session. */ + publish: (session: Composition) => void; /** * Force a session reload immediately, bypassing the self-write suppress * window. Call after undo/redo writes the active composition file so the @@ -96,6 +99,7 @@ export function useSdkSession( domEditSaveTimestampRef?: MutableRefObject, ): SdkSessionHandle { const [session, setSession] = useState(null); + const sessionRef = useRef(null); const [reloadToken, setReloadToken] = useState(0); // ── Re-open on external change to the active composition ── @@ -160,8 +164,11 @@ export function useSdkSession( comp.dispose(); return; } + const previous = sessionRef.current; compRef.current = comp; + sessionRef.current = comp; setSession(comp); + if (previous && previous !== comp) previous.dispose(); }) .catch(() => { if (!cancelled) setSession(null); @@ -171,10 +178,28 @@ export function useSdkSession( cancelled = true; // 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(); + const owned = compRef.current; + if (sessionRef.current === owned) sessionRef.current = null; + owned?.dispose(); }; }, [projectId, activeCompPath, reloadToken]); const forceReload = useCallback(() => setReloadToken((t) => t + 1), []); - return { session, forceReload }; + const publish = useCallback((candidate: Composition) => { + const previous = sessionRef.current; + sessionRef.current = candidate; + setSession(candidate); + if (previous && previous !== candidate) { + try { + previous.dispose(); + } catch (error) { + // Publication already succeeded. Disposal is cleanup, not part of the + // transaction, and must never make the caller roll back a live session. + trackStudioEvent("sdk_session_dispose_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + }, []); + return { session, publish, forceReload }; } diff --git a/packages/studio/src/hooks/useSlideshowPersist.ts b/packages/studio/src/hooks/useSlideshowPersist.ts index b4a525946e..855a0d16db 100644 --- a/packages/studio/src/hooks/useSlideshowPersist.ts +++ b/packages/studio/src/hooks/useSlideshowPersist.ts @@ -16,6 +16,8 @@ export interface UseSlideshowPersistParams { }) => Promise; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; + /** Publish a fully persisted candidate SDK session. */ + publishSdkSession?: (session: Composition) => void; /** * When provided, rapid writes with the same key coalesce through the * save-queue infra (via recordEdit's coalesceKey) so back-to-back persists @@ -33,6 +35,7 @@ export function useSlideshowPersist({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, coalesceKey, }: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise { return useCallback( @@ -50,6 +53,8 @@ export function useSlideshowPersist({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, + readProjectFile, + publishSession: publishSdkSession, }, coalesceKey, }); @@ -62,6 +67,7 @@ export function useSlideshowPersist({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, coalesceKey, ], ); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index b873f85178..82f7f4bb94 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -39,7 +39,7 @@ import { useTimelineTrackVisibilityEditing, } from "./timelineTrackVisibility"; import { useTimelineGroupEditing } from "./useTimelineGroupEditing"; -import { sdkTimingPersist } from "../utils/sdkCutover"; +import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; type TimelineMoveUpdates = Pick & { @@ -60,6 +60,7 @@ export function useTimelineEditing({ uploadProjectFiles, isRecordingRef, sdkSession, + publishSdkSession, forceReloadSdkSession, handleDomZIndexReorderCommitRef, }: UseTimelineEditingOptions) { @@ -128,6 +129,7 @@ export function useTimelineEditing({ recordEdit, reloadPreview, sdkSession, + publishSdkSession, showToast, writeProjectFile, }); @@ -198,13 +200,12 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, - // Capture on-disk bytes as the undo `before` so undoing a timing move - // restores the file verbatim, not a normalized full-DOM re-emit. readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + publishSession: publishSdkSession, }, { label: "Move timeline clip", coalesceKey }, - ).then((handled) => { - if (!handled) return moveFallback(); + ).then((result) => { + if (!cutoverCommittedOrThrow(result)) return moveFallback(); }); } return moveFallback(); @@ -215,6 +216,7 @@ export function useTimelineEditing({ enqueueEdit, activeCompPath, sdkSession, + publishSdkSession, recordEdit, writeProjectFile, reloadPreview, @@ -234,9 +236,6 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], ]; - // Patch the live playback-start/media-start attr too, or a resize that - // trims the playback start leaves the preview showing the old in-point - // until the next reload (the persisted patch handles it via pbs below). if (updates.playbackStart != null) { const liveAttr = element.playbackStartAttr === "playback-start" @@ -252,9 +251,6 @@ export function useTimelineEditing({ const hasPbsAdjustment = updates.playbackStart != null || (updates.start !== element.start && element.playbackStart != null); - // Server-path fallback: after persisting the attr patch, scale GSAP tween - // positions/durations on the server. Extending edits can keep the iframe - // live unless a GSAP source rewrite needs a fresh run. const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; const timingChanged = updates.start !== element.start || updates.duration !== element.duration; @@ -296,13 +292,12 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, - // Capture on-disk bytes as the undo `before` so undoing a timing - // resize restores the file verbatim, not a normalized full-DOM re-emit. readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + publishSession: publishSdkSession, }, { label: "Resize timeline clip", coalesceKey }, - ).then((handled) => { - if (!handled) return resizeFallback(); + ).then((result) => { + if (!cutoverCommittedOrThrow(result)) return resizeFallback(); }); } return resizeFallback(); @@ -312,6 +307,7 @@ export function useTimelineEditing({ enqueueEdit, activeCompPath, sdkSession, + publishSdkSession, recordEdit, writeProjectFile, reloadPreview, diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index 6689169a8d..88c4b480d3 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -40,6 +40,8 @@ export interface UseTimelineEditingOptions { isRecordingRef?: RefObject; /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ sdkSession?: Composition | null; + /** Publish a fully persisted candidate SDK session. */ + publishSdkSession?: (session: Composition) => void; /** Resync the SDK session after a server-authoritative timeline write. */ forceReloadSdkSession?: () => void; handleDomZIndexReorderCommitRef?: MutableRefObject; diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 7225b50d59..a7ada65439 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -1,7 +1,7 @@ import { useCallback, type MutableRefObject, type RefObject } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; -import { sdkTimingBatchPersist } from "../utils/sdkCutover"; +import { cutoverCommittedOrThrow, sdkTimingBatchPersist } from "../utils/sdkCutover"; import { buildTimelineMoveTimingPatch, buildTimelineResizeTimingPatch, @@ -48,6 +48,7 @@ interface UseTimelineGroupEditingOptions { recordEdit: (input: RecordEditInput) => Promise; reloadPreview: () => void; sdkSession?: Composition | null; + publishSdkSession?: (session: Composition) => void; showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string) => Promise; } @@ -94,6 +95,7 @@ export function useTimelineGroupEditing({ recordEdit, reloadPreview, sdkSession, + publishSdkSession, showToast, writeProjectFile, }: UseTimelineGroupEditingOptions) { @@ -190,10 +192,11 @@ export function useTimelineGroupEditing({ domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + publishSession: publishSdkSession, }, { label: "Move timeline clips", coalesceKey }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } await persistServerBatch( @@ -255,6 +258,7 @@ export function useTimelineGroupEditing({ recordEdit, reloadPreview, sdkSession, + publishSdkSession, writeProjectFile, ], ); @@ -308,10 +312,11 @@ export function useTimelineGroupEditing({ domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + publishSession: publishSdkSession, }, { label: "Resize timeline clips", coalesceKey }, ); - if (handled) return; + if (cutoverCommittedOrThrow(handled)) return; } await persistServerBatch( @@ -376,6 +381,7 @@ export function useTimelineGroupEditing({ recordEdit, reloadPreview, sdkSession, + publishSdkSession, writeProjectFile, ], ); diff --git a/packages/studio/src/hooks/useVariablesPersist.ts b/packages/studio/src/hooks/useVariablesPersist.ts index 7d148d9294..7d42a72487 100644 --- a/packages/studio/src/hooks/useVariablesPersist.ts +++ b/packages/studio/src/hooks/useVariablesPersist.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; -import { persistSdkSerialize } from "../utils/sdkCutover"; +import { cutoverCommittedOrThrow, persistSdkCandidateMutation } from "../utils/sdkCutover"; import type { UseSlideshowPersistParams } from "./useSlideshowPersist"; /** Same single-writer dependency set the slideshow persist path uses. */ @@ -21,6 +21,7 @@ export function useVariablesPersist({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, }: UseVariablesPersistParams): ( label: string, mutate: (session: Composition) => void, @@ -30,11 +31,8 @@ export function useVariablesPersist({ if (!sdkSession) return false; const path = activeCompPath ?? "index.html"; const originalContent = await readProjectFile(path); - mutate(sdkSession); - const after = sdkSession.serialize(); - if (after === originalContent) return false; - await persistSdkSerialize( - after, + const result = await persistSdkCandidateMutation( + sdkSession, path, originalContent, { @@ -43,10 +41,13 @@ export function useVariablesPersist({ reloadPreview, domEditSaveTimestampRef, compositionPath: activeCompPath, + readProjectFile, + publishSession: publishSdkSession, }, + mutate, { label }, ); - return true; + return cutoverCommittedOrThrow(result); }, [ sdkSession, @@ -56,6 +57,7 @@ export function useVariablesPersist({ recordEdit, reloadPreview, domEditSaveTimestampRef, + publishSdkSession, ], ); } diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts index e5d122cc92..d5672ab9fc 100644 --- a/packages/studio/src/utils/sdkCutover.gate.test.ts +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; // Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover -// persist chokepoint must return false so the caller takes the legacy server +// persist chokepoint must explicitly decline so the caller takes the legacy server // path — even when a valid SDK session exists (one always does, for // shadow/selection). This is the contract the prod flag-flip rests on; a future // refactor of the gate guards that silently re-enables cutover on flag-off @@ -31,12 +31,12 @@ const makeDeps = () => domEditSaveTimestampRef: { current: 0 }, }) as never; -describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => { +describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist declines", () => { it("sdkTimingPersist falls back without writing", async () => { const deps = makeDeps(); - expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe( - false, - ); + expect( + await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps), + ).toMatchObject({ status: "declined", reason: "feature_disabled" }); expect( (deps as unknown as { writeProjectFile: ReturnType }).writeProjectFile, ).not.toHaveBeenCalled(); @@ -50,12 +50,12 @@ describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist retu makeSession(), makeDeps(), ), - ).toBe(false); + ).toMatchObject({ status: "declined", reason: "feature_disabled" }); }); it("sdkDeletePersist falls back", async () => { expect( await sdkDeletePersist("hf-a", "", "/c.html", makeSession(), makeDeps()), - ).toBe(false); + ).toMatchObject({ status: "declined", reason: "feature_disabled" }); }); }); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 5b0bf6e7a7..f8381c347e 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -6,6 +6,8 @@ import { sdkTimingPersist, sdkGsapTweenPersist, sdkGsapKeyframePersist, + cutoverCommittedOrThrow, + persistSdkCandidateMutation, } from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; @@ -44,6 +46,14 @@ const htmlAttrOp = (property: string, value: string): PatchOperation => ({ value, }); +const candidateTestDeps = () => ({ + publishSession: vi.fn(), + createCandidateSession: async ( + _serialized: string, + live: Parameters[4], + ) => live!, +}); + describe("shouldUseSdkCutover", () => { it("returns false when flag disabled", () => { expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); @@ -148,6 +158,7 @@ describe("sdkCutoverPersist", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), ...overrides, }); @@ -175,7 +186,7 @@ describe("sdkCutoverPersist", () => { null, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("declined"); }); it("returns false when element not found in session", async () => { @@ -190,7 +201,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("declined"); }); it("dispatches setStyle for inline-style ops", async () => { @@ -205,7 +216,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.dispatch).toHaveBeenCalledWith({ type: "setStyle", target: "hf-abc", @@ -227,7 +238,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.dispatch).toHaveBeenCalledWith({ type: "setText", target: "hf-abc", @@ -251,7 +262,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("declined"); expect(session!.dispatch).not.toHaveBeenCalled(); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); @@ -268,7 +279,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.dispatch).toHaveBeenCalledWith({ type: "setAttribute", target: "hf-abc", @@ -289,7 +300,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.dispatch).toHaveBeenCalledWith({ type: "setAttribute", target: "hf-abc", @@ -337,7 +348,7 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); @@ -376,12 +387,195 @@ describe("sdkCutoverPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); }); +describe("transactional SDK candidate publication", () => { + const html = ` +
+
+`; + + it.each([ + [ + "style", + (session: Awaited>) => + session.setStyle("hf-box", { color: "red" }), + ], + [ + "timing", + (session: Awaited>) => + session.setTiming("hf-box", { start: 2 }), + ], + [ + "delete", + (session: Awaited>) => session.removeElement("hf-box"), + ], + [ + "variables", + (session: Awaited>) => + session.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" }), + ], + [ + "grouping/structure", + (session: Awaited>) => + session.addElement(null, 0, '
'), + ], + [ + "GSAP", + (session: Awaited>) => { + const animationId = session.getElement("hf-box")?.animationIds[0]; + if (!animationId) throw new Error("missing fixture animation"); + session.setGsapTween(animationId, { ease: "power2.in" }); + }, + ], + ])( + "restores disk and keeps the live session unchanged when %s history fails", + async (_name, mutate) => { + const live = await openComposition(html, { history: false }); + const liveBefore = live.serialize(); + let disk = html; + const publishSession = vi.fn(); + const result = await persistSdkCandidateMutation( + live, + "/comp.html", + html, + { + editHistory: { recordEdit: vi.fn().mockRejectedValue(new Error("history failed")) }, + writeProjectFile: vi.fn(async (_path: string, content: string) => { + disk = content; + }), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + publishSession, + }, + mutate, + { label: `Edit ${_name}` }, + ); + + expect(result.status).toBe("failed"); + expect(disk).toBe(html); + expect(live.serialize()).toBe(liveBefore); + expect(publishSession).not.toHaveBeenCalled(); + expect(() => cutoverCommittedOrThrow(result)).toThrow("history failed"); + live.dispose(); + }, + ); + + it("publishes the candidate only after write and history commit", async () => { + const live = await openComposition(html, { history: false }); + const liveBefore = live.serialize(); + const order: string[] = []; + let published: Awaited> | undefined; + const result = await persistSdkCandidateMutation( + live, + "/comp.html", + html, + { + editHistory: { + recordEdit: vi.fn(async () => { + order.push("history"); + }), + }, + writeProjectFile: vi.fn(async () => { + order.push("write"); + }), + reloadPreview: vi.fn(() => order.push("refresh")), + domEditSaveTimestampRef: { current: 0 }, + publishSession: (candidate) => { + order.push("publish"); + published = candidate; + }, + }, + (candidate) => candidate.setStyle("hf-box", { color: "red" }), + ); + + expect(result.status).toBe("committed"); + expect(order).toEqual(["write", "history", "publish", "refresh"]); + expect(live.serialize()).toBe(liveBefore); + expect(published?.serialize()).toContain("color: red"); + live.dispose(); + published?.dispose(); + }); + + it("keeps the durable commit authoritative when a publisher throws after publication", async () => { + const live = await openComposition(html, { history: false }); + let disk = html; + let published: Awaited> | undefined; + const recordEdit = vi.fn().mockResolvedValue(undefined); + const writeProjectFile = vi.fn(async (_path: string, content: string) => { + disk = content; + }); + const result = await persistSdkCandidateMutation( + live, + "/comp.html", + html, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + publishSession: (candidate) => { + published = candidate; + throw new Error("cleanup after publish failed"); + }, + }, + (candidate) => candidate.setStyle("hf-box", { color: "red" }), + ); + + expect(result.status).toBe("committed"); + expect(disk).toContain("color: red"); + expect(writeProjectFile).toHaveBeenCalledTimes(1); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(published?.serialize()).toContain("color: red"); + live.dispose(); + published?.dispose(); + }); + + it("serializes overlapping SDK edits and rebases each candidate on the latest disk bytes", async () => { + const live = await openComposition(html, { history: false }); + let disk = html; + const published: Array>> = []; + const writeProjectFile = vi.fn(async (_path: string, content: string) => { + // Yield inside the write to make overlap deterministic. + await Promise.resolve(); + disk = content; + }); + const deps = { + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile, + readProjectFile: vi.fn(async () => disk), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + publishSession: (candidate: Awaited>) => { + published.push(candidate); + }, + }; + + const [first, second] = await Promise.all([ + persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) => + candidate.setStyle("hf-box", { color: "red" }), + ), + persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) => + candidate.setStyle("hf-box", { backgroundColor: "blue" }), + ), + ]); + + expect(first.status).toBe("committed"); + expect(second.status).toBe("committed"); + expect(disk).toContain("color: red"); + expect(disk).toContain("background-color: blue"); + expect(writeProjectFile).toHaveBeenCalledTimes(2); + live.dispose(); + for (const candidate of published) candidate.dispose(); + }); +}); + describe("sdkDeletePersist", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ @@ -389,6 +583,7 @@ describe("sdkDeletePersist", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), }); const makeSession = (hasEl = true) => @@ -403,21 +598,23 @@ describe("sdkDeletePersist", () => { }) as unknown as Parameters[3]; it("returns false when session is null", async () => { - expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false); + expect( + (await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).status, + ).toBe("declined"); }); it("returns false when element not found in session", async () => { const session = makeSession(false); - expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe( - false, - ); + expect( + (await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).status, + ).toBe("declined"); }); it("calls removeElement and writes serialized content", async () => { const deps = makeDeps(); const session = makeSession(true); const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.removeElement).toHaveBeenCalledWith("hf-abc"); expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); }); @@ -448,7 +645,7 @@ describe("sdkDeletePersist", () => { throw new Error("remove failed"); }); const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); @@ -461,6 +658,7 @@ describe("sdkTimingPersist", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), }); const makeSession = (hasEl = true) => @@ -475,16 +673,16 @@ describe("sdkTimingPersist", () => { }) as unknown as Parameters[3]; it("returns false when session is null", async () => { - expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe( - false, - ); + expect( + (await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).status, + ).toBe("declined"); }); it("returns false when element not found in session", async () => { const session = makeSession(false); - expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe( - false, - ); + expect( + (await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).status, + ).toBe("declined"); }); it("calls setTiming with provided update and writes serialized content", async () => { @@ -497,7 +695,7 @@ describe("sdkTimingPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", { start: 2, duration: 5, @@ -524,7 +722,7 @@ describe("sdkTimingPersist", () => { throw new Error("timing error"); }); const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); @@ -583,6 +781,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), readProjectFile: vi.fn().mockResolvedValue("on-disk gsap bytes"), + ...candidateTestDeps(), }; const session = makeSession(); await sdkGsapTweenPersist( @@ -626,6 +825,7 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { }), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), // A real per-key serializer: tasks under the same key run strictly in order. serialize: (() => { const inFlight = new Map>(); @@ -662,9 +862,8 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { session, deps, ); - // Let the first op reach its (blocked) write before releasing it. - await Promise.resolve(); - await Promise.resolve(); + // Let the first op finish candidate construction and reach its blocked write. + await vi.waitFor(() => expect(writeResolve).not.toBeNull()); writeResolve?.(); await Promise.all([p1, p2]); @@ -683,6 +882,7 @@ describe("sdkGsapTweenPersist", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), }); const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) => @@ -706,7 +906,7 @@ describe("sdkGsapTweenPersist", () => { null, makeDeps(), ), - ).toBe(false); + ).toMatchObject({ status: "declined" }); }); it("calls addGsapTween and writes for kind=add", async () => { @@ -722,7 +922,7 @@ describe("sdkGsapTweenPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.addGsapTween).toHaveBeenCalledWith( "hf-box", expect.objectContaining({ method: "to" }), @@ -739,7 +939,7 @@ describe("sdkGsapTweenPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("declined"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); @@ -752,7 +952,7 @@ describe("sdkGsapTweenPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" }); expect(deps.reloadPreview).toHaveBeenCalled(); }); @@ -766,7 +966,7 @@ describe("sdkGsapTweenPersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1"); }); @@ -782,7 +982,7 @@ describe("sdkGsapTweenPersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); }); @@ -794,6 +994,7 @@ describe("sdkGsapKeyframePersist", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), }); const makeSession = () => @@ -809,7 +1010,7 @@ describe("sdkGsapKeyframePersist", () => { it("returns false when session is null", async () => { expect( await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()), - ).toBe(false); + ).toMatchObject({ status: "declined" }); }); it("dispatches addGsapKeyframe and writes serialized content", async () => { @@ -823,7 +1024,7 @@ describe("sdkGsapKeyframePersist", () => { session, deps, ); - expect(result).toBe(true); + expect(result.status).toBe("committed"); expect(session!.dispatch).toHaveBeenCalledWith({ type: "addGsapKeyframe", animationId: "tw-1", @@ -848,7 +1049,7 @@ describe("sdkGsapKeyframePersist", () => { session, deps, ); - expect(result).toBe(false); + expect(result.status).toBe("failed"); expect(deps.writeProjectFile).not.toHaveBeenCalled(); }); }); @@ -860,6 +1061,7 @@ describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), domEditSaveTimestampRef: makeRef(0), + ...candidateTestDeps(), }); it("preserves GSAP