fix(studio,core): resolve SDK-cutover review findings (#1471)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:44:45 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent 7ca4490328
commit 377b0368bd
14 changed files with 343 additions and 338 deletions
+8 -10
View File
@@ -190,6 +190,7 @@ export function StudioApp() {
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession: sdkHandle.session,
forceReloadSdkSession: sdkHandle.forceReload,
});
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: sdkHandle.session,
forceReloadSdkSession: sdkHandle.forceReload,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
clearDomSelectionRef.current = domEditSession.clearDomSelection;
@@ -67,4 +67,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<void>;
/** Resync the in-memory SDK session after a server-authoritative write. */
forceReloadSdkSession?: () => void;
}
+16 -9
View File
@@ -73,14 +73,17 @@ export interface UseDomEditCommitsParams {
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
/** 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<boolean>;
/** 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<boolean>;
@@ -104,7 +107,7 @@ export function useDomEditCommits({
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted,
forceReloadSdkSession,
onTrySdkPersist,
onTrySdkDelete,
}: UseDomEditCommitsParams) {
@@ -156,19 +159,22 @@ export function useDomEditCommits({
}
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 &&
!options?.prepareContent &&
(await onTrySdkPersist(selection, operations, originalContent, targetPath))
(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.
// 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);
const patchBody = { target: patchTarget, operations };
const unsafeFields = findUnsafeDomPatchValues(patchBody);
@@ -240,7 +246,7 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
onDomEditPersisted?.(selection, operations);
forceReloadSdkSession?.();
if (!options?.skipRefresh) {
reloadPreview();
@@ -254,7 +260,7 @@ export function useDomEditCommits({
domEditSaveTimestampRef,
reloadPreview,
showToast,
onDomEditPersisted,
forceReloadSdkSession,
onTrySdkPersist,
],
);
@@ -317,6 +323,7 @@ export function useDomEditCommits({
reloadPreview,
clearDomSelection,
onTrySdkDelete,
forceReloadSdkSession,
commitPositionPatchToHtml,
});
+21 -7
View File
@@ -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 ──
@@ -234,14 +237,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) =>
@@ -250,6 +263,7 @@ export function useDomEditSession({
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
})
: undefined,
});
@@ -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<boolean>;
/** Resync the SDK session after a server-fallback delete. */
forceReloadSdkSession?: () => void;
commitPositionPatchToHtml: (
selection: DomEditSelection,
patches: PatchOperation[],
@@ -47,6 +49,7 @@ export function useElementLifecycleOps({
reloadPreview,
clearDomSelection,
onTrySdkDelete,
forceReloadSdkSession,
commitPositionPatchToHtml,
onElementDeleted,
}: UseElementLifecycleOpsParams) {
@@ -106,6 +109,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",
@@ -118,6 +127,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();
onElementDeleted?.(selection);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
@@ -133,6 +145,7 @@ export function useElementLifecycleOps({
editHistory.recordEdit,
onTrySdkDelete,
onElementDeleted,
forceReloadSdkSession,
projectIdRef,
reloadPreview,
showToast,
@@ -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<void>;
editHistory?: {
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
reloadPreview?: () => void;
domEditSaveTimestampRef?: React.MutableRefObject<number>;
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 {
+31 -58
View File
@@ -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<void>;
editHistory?: {
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
reloadPreview?: () => void;
domEditSaveTimestampRef?: React.MutableRefObject<number>;
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<string, number | string>,
) => {
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(
@@ -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<void>;
editHistory?: {
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
reloadPreview?: () => void;
domEditSaveTimestampRef?: React.MutableRefObject<number>;
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<ReturnType<typeof setTimeout> | 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(
@@ -1,7 +1,8 @@
import { useCallback, useRef } from "react";
import { useCallback, useMemo, useRef } 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 { createKeyedSerializer } from "./serializeByKey";
import {
@@ -44,7 +45,7 @@ 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) {
// 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
@@ -75,6 +76,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?.();
@@ -84,7 +88,7 @@ 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]);
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
// commits to the SAME file (any op type, any animation) interleave server-side,
// so serialize per target file by default; an explicit serializeKey overrides.
@@ -98,12 +102,44 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
);
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<CutoverDeps | null>(
() =>
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({
@@ -113,10 +149,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
commitMutationSafely,
showToast,
sdkSession,
writeProjectFile,
editHistory,
reloadPreview,
domEditSaveTimestampRef,
sdkDeps,
});
const keyframeOps = useGsapKeyframeOps({
activeCompPath,
@@ -124,10 +157,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 };
+12 -15
View File
@@ -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]);
+40 -15
View File
@@ -58,6 +58,8 @@ interface UseTimelineEditingOptions {
isRecordingRef?: React.RefObject<boolean>;
/** 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 ──
@@ -76,6 +78,7 @@ export function useTimelineEditing({
uploadProjectFiles,
isRecordingRef,
sdkSession,
forceReloadSdkSession,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
@@ -95,19 +98,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);
});
@@ -121,6 +129,7 @@ export function useTimelineEditing({
pendingTimelineEditPathRef,
showToast,
isRecordingRef,
forceReloadSdkSession,
],
);
@@ -151,7 +160,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);
@@ -215,7 +230,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);
@@ -292,6 +313,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) {
@@ -308,6 +330,7 @@ export function useTimelineEditing({
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
@@ -376,6 +399,7 @@ export function useTimelineEditing({
recordEdit,
});
forceReloadSdkSession?.();
reloadPreview();
} catch (error) {
const message =
@@ -392,6 +416,7 @@ export function useTimelineEditing({
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
],
);
@@ -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[] = [];
+14 -2
View File
@@ -104,7 +104,12 @@ describe("sdkCutoverPersist", () => {
({
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
dispatch: vi.fn(),
serialize: vi.fn().mockReturnValue("<html></html>"),
// 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("<html>before</html>")
.mockReturnValue("<html></html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
@@ -318,7 +323,11 @@ describe("sdkDeletePersist", () => {
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
removeElement: vi.fn(),
serialize: vi.fn().mockReturnValue("<html>after</html>"),
serialize: vi
.fn()
.mockReturnValueOnce("<html>before-snap</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
it("returns false when session is null", async () => {
@@ -390,6 +399,7 @@ describe("sdkTimingPersist", () => {
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
it("returns false when session is null", async () => {
@@ -466,6 +476,7 @@ describe("sdkGsapTweenPersist", () => {
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
it("returns false when session is null", async () => {
@@ -573,6 +584,7 @@ describe("sdkGsapKeyframePersist", () => {
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkGsapKeyframePersist>[4];
it("returns false when session is null", async () => {
+69 -21
View File
@@ -64,7 +64,7 @@ export function shouldUseSdkCutover(
);
}
interface CutoverDeps {
export interface CutoverDeps {
editHistory: {
recordEdit: (entry: {
label: string;
@@ -76,22 +76,44 @@ interface CutoverDeps {
writeProjectFile: (path: string, content: string) => Promise<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
/**
* 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<void> {
const after = sdkSession.serialize();
deps.domEditSaveTimestampRef.current = Date.now();
await deps.writeProjectFile(targetPath, after);
await deps.editHistory.recordEdit({
@@ -100,7 +122,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(
@@ -118,13 +141,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) {
@@ -145,10 +172,13 @@ export async function sdkTimingPersist(
options?: CutoverOptions,
): Promise<boolean> {
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) {
@@ -170,17 +200,26 @@ export async function sdkGsapTweenPersist(
options?: CutoverOptions,
): Promise<boolean> {
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) {
@@ -199,10 +238,15 @@ export async function sdkGsapKeyframePersist(
options?: CutoverOptions,
): Promise<boolean> {
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) {
@@ -219,9 +263,13 @@ export async function sdkDeletePersist(
deps: CutoverDeps,
): Promise<boolean> {
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 });