feat(studio): route element delete through SDK removeElement (§3.1) (#1465)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:38:38 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent 8585fffc92
commit bce571c2a1
5 changed files with 142 additions and 16 deletions
+69 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover";
import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover";
import { openComposition } from "@hyperframes/sdk";
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
import type { PatchOperation } from "./sourcePatcher";
@@ -298,6 +298,74 @@ describe("sdkCutoverPersist", () => {
});
});
describe("sdkDeletePersist", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
const makeSession = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
removeElement: vi.fn(),
serialize: vi.fn().mockReturnValue("<html>after</html>"),
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
it("returns false when session is null", async () => {
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
});
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,
);
});
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(session!.removeElement).toHaveBeenCalledWith("hf-abc");
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
});
it("records edit history with before/after diff", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
label: "Delete element",
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
}),
);
});
it("calls reloadPreview on success", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("returns false and does not write on removeElement error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("remove failed");
});
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = () => ({
+42 -10
View File
@@ -83,6 +83,26 @@ interface CutoverOptions {
coalesceKey?: string;
}
// ponytail: internal; export only if a third caller appears
async function persistSdkSerialize(
sdkSession: Composition,
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({
label: options?.label ?? "Edit layer",
kind: "manual",
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
files: { [targetPath]: { before: originalContent, after } },
});
deps.reloadPreview();
}
export async function sdkCutoverPersist(
selection: DomEditSelection,
ops: PatchOperation[],
@@ -104,16 +124,7 @@ export async function sdkCutoverPersist(
sdkSession.dispatch(editOp);
}
});
const after = sdkSession.serialize();
deps.domEditSaveTimestampRef.current = Date.now();
await deps.writeProjectFile(targetPath, after);
await deps.editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
files: { [targetPath]: { before: originalContent, after } },
});
deps.reloadPreview();
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
return true;
} catch (err) {
@@ -124,3 +135,24 @@ export async function sdkCutoverPersist(
return false;
}
}
export async function sdkDeletePersist(
hfId: string,
originalContent: string,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
): Promise<boolean> {
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
try {
sdkSession.removeElement(hfId);
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, {
label: "Delete element",
});
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
return false;
}
}