From 8788bf187d1efab8447266af17c90e2fa2e13bad Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 16:36:38 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(studio):=20stage=207=20step=203c=20?= =?UTF-8?q?=E2=80=94=20sdk=20cutover=20for=20inline-style=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/manualEditingAvailability.ts | 9 ++ .../src/hooks/useDomEditSession.test.ts | 50 +++++++ packages/studio/src/utils/sdkCutover.test.ts | 141 ++++++++++++++++++ packages/studio/src/utils/sdkCutover.ts | 74 +++++++++ 4 files changed, 274 insertions(+) create mode 100644 packages/studio/src/hooks/useDomEditSession.test.ts create mode 100644 packages/studio/src/utils/sdkCutover.test.ts create mode 100644 packages/studio/src/utils/sdkCutover.ts diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index e2d74d8ad..8d8a49ba1 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -97,4 +97,13 @@ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( false, ); +// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch +// instead of the server patch-element API. Default false; enable via +// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open. +export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_SDK_CUTOVER_ENABLED"], + false, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/useDomEditSession.test.ts b/packages/studio/src/hooks/useDomEditSession.test.ts new file mode 100644 index 000000000..f8cb00522 --- /dev/null +++ b/packages/studio/src/hooks/useDomEditSession.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { shouldUseSdkCutover } from "../utils/sdkCutover"; +import type { PatchOperation } from "../utils/sourcePatcher"; + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag is disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no SDK session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when selection has no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops include non-inline-style types", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), + ).toBe(false); + }); + + it("returns false when ops array is empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true when flag on, session present, hfId set, all ops inline-style", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + expect( + shouldUseSdkCutover(true, true, "hf-abc", [ + styleOp("color", "red"), + styleOp("opacity", "0.5"), + ]), + ).toBe(true); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts new file mode 100644 index 000000000..c7438a220 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover"; +import type { PatchOperation } from "./sourcePatcher"; +import type { MutableRefObject } from "react"; + +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: true, +})); +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: vi.fn(), +})); + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when flag disabled", () => { + expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no session", () => { + expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no hfId", () => { + expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops include non-inline-style types", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("x", "1")]), + ).toBe(false); + }); + + it("returns false when ops empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + it("returns true when all conditions met", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + }); +}); + +describe("sdkCutoverPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + const makeDeps = (overrides: Partial[5]> = {}) => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + ...overrides, + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), + dispatch: vi.fn(), + serialize: vi.fn().mockReturnValue(""), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + const deps = makeDeps(); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + null, + deps, + ); + expect(result).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const deps = makeDeps(); + const session = makeSession(false); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + session, + deps, + ); + expect(result).toBe(false); + }); + + it("dispatches setStyle and writes file on success", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setStyle", + target: "hf-abc", + styles: { color: "red", opacity: "0.5" }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", ""); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not throw on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts new file mode 100644 index 000000000..2156f0bd7 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.ts @@ -0,0 +1,74 @@ +import type { MutableRefObject } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "./editHistory"; +import type { PatchOperation } from "./sourcePatcher"; +import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "./studioTelemetry"; + +export function shouldUseSdkCutover( + flagEnabled: boolean, + hasSession: boolean, + hfId: string | null | undefined, + ops: PatchOperation[], +): boolean { + return ( + flagEnabled && + hasSession && + !!hfId && + ops.length > 0 && + ops.every((o) => o.type === "inline-style") + ); +} + +interface CutoverDeps { + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + writeProjectFile: (path: string, content: string) => Promise; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; +} + +export async function sdkCutoverPersist( + selection: DomEditSelection, + ops: PatchOperation[], + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, +): Promise { + if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) + return false; + if (!sdkSession) return false; + const hfId = selection.hfId; + if (!hfId) return false; + if (!sdkSession.getElement(hfId)) return false; + try { + const styles: Record = {}; + for (const op of ops) styles[op.property] = op.value; + sdkSession.dispatch({ type: "setStyle", target: hfId, styles }); + const after = sdkSession.serialize(); + deps.domEditSaveTimestampRef.current = Date.now(); + await deps.writeProjectFile(targetPath, after); + await deps.editHistory.recordEdit({ + label: "Edit layer", + kind: "manual", + files: { [targetPath]: { before: originalContent, after } }, + }); + deps.reloadPreview(); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: selection.hfId ?? null, + error: String(err), + }); + return false; + } +} From 3f742d974547b632ba26096b7bcaa81a68565d01 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 16:52:35 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(studio):=20stage=207=20step=203d=20?= =?UTF-8?q?=E2=80=94=20extend=20sdk=20cutover=20to=20all=20op=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/useDomEditSession.test.ts | 13 +-- packages/studio/src/utils/sdkCutover.test.ts | 107 ++++++++++++++++-- packages/studio/src/utils/sdkCutover.ts | 16 ++- 3 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditSession.test.ts b/packages/studio/src/hooks/useDomEditSession.test.ts index f8cb00522..040d83b3b 100644 --- a/packages/studio/src/hooks/useDomEditSession.test.ts +++ b/packages/studio/src/hooks/useDomEditSession.test.ts @@ -28,23 +28,14 @@ describe("shouldUseSdkCutover", () => { expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); }); - it("returns false when ops include non-inline-style types", () => { - expect( - shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), - ).toBe(false); - }); - it("returns false when ops array is empty", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); }); - it("returns true when flag on, session present, hfId set, all ops inline-style", () => { + it("returns true when all conditions met with supported op types", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); expect( - shouldUseSdkCutover(true, true, "hf-abc", [ - styleOp("color", "red"), - styleOp("opacity", "0.5"), - ]), + shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), ).toBe(true); }); }); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index c7438a220..d2c8cfb9f 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -16,12 +16,24 @@ const styleOp = (property: string, value: string): PatchOperation => ({ value, }); +const textOp = (value: string): PatchOperation => ({ + type: "text-content", + property: "text", + value, +}); + const attrOp = (property: string, value: string): PatchOperation => ({ type: "attribute", property, value, }); +const htmlAttrOp = (property: string, value: string): PatchOperation => ({ + type: "html-attribute", + property, + value, +}); + describe("shouldUseSdkCutover", () => { it("returns false when flag disabled", () => { expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); @@ -36,19 +48,36 @@ describe("shouldUseSdkCutover", () => { expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false); }); - it("returns false when ops include non-inline-style types", () => { - expect( - shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("x", "1")]), - ).toBe(false); - }); - it("returns false when ops empty", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); }); - it("returns true when all conditions met", () => { + it("returns true for inline-style ops", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); }); + + it("returns true for text-content ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true); + }); + + it("returns true for attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true); + }); + + it("returns true for html-attribute ops", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true); + }); + + it("returns true when ops mix all supported types", () => { + expect( + shouldUseSdkCutover(true, true, "hf-abc", [ + styleOp("color", "red"), + textOp("hello"), + attrOp("x", "1"), + htmlAttrOp("class", "foo"), + ]), + ).toBe(true); + }); }); describe("sdkCutoverPersist", () => { @@ -98,7 +127,7 @@ describe("sdkCutoverPersist", () => { expect(result).toBe(false); }); - it("dispatches setStyle and writes file on success", async () => { + it("dispatches setStyle for inline-style ops", async () => { const deps = makeDeps(); const session = makeSession(true); const sel = { hfId: "hf-abc" } as never; @@ -120,6 +149,68 @@ describe("sdkCutoverPersist", () => { expect(deps.reloadPreview).toHaveBeenCalled(); }); + it("dispatches setText for text-content op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [textOp("Hello world")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setText", + target: "hf-abc", + value: "Hello world", + }); + }); + + it("dispatches setAttribute for attribute op with data- prefix", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [attrOp("x", "42")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "data-x", + value: "42", + }); + }); + + it("dispatches setAttribute for html-attribute op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [htmlAttrOp("class", "foo bar")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "class", + value: "foo bar", + }); + }); + it("returns false and does not throw on dispatch error", async () => { const deps = makeDeps(); const session = makeSession(true); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 2156f0bd7..a6191be94 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -4,8 +4,16 @@ import type { DomEditSelection } from "../components/editor/domEditing"; import type { EditHistoryKind } from "./editHistory"; import type { PatchOperation } from "./sourcePatcher"; import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; +import { patchOpsToSdkEditOps } from "./sdkShadow"; import { trackStudioEvent } from "./studioTelemetry"; +const CUTOVER_OP_TYPES = new Set([ + "inline-style", + "text-content", + "attribute", + "html-attribute", +]); + export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, @@ -17,7 +25,7 @@ export function shouldUseSdkCutover( hasSession && !!hfId && ops.length > 0 && - ops.every((o) => o.type === "inline-style") + ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) ); } @@ -50,9 +58,9 @@ export async function sdkCutoverPersist( if (!hfId) return false; if (!sdkSession.getElement(hfId)) return false; try { - const styles: Record = {}; - for (const op of ops) styles[op.property] = op.value; - sdkSession.dispatch({ type: "setStyle", target: hfId, styles }); + for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { + sdkSession.dispatch(editOp); + } const after = sdkSession.serialize(); deps.domEditSaveTimestampRef.current = Date.now(); await deps.writeProjectFile(targetPath, after); From e33dd14026fda2d40ccbbbb2a65e6f5cd1765802 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 18:39:00 -0700 Subject: [PATCH 3/8] fix(sdk,studio): 14 code-review correctness fixes from stage 7 review - http adapter: throw on 5xx instead of silently returning undefined - fs adapter: serialize appendVersion to prevent concurrent pruning race - mutate: GSAP handlers throw (not silent EMPTY) when no GSAP script block - mutate: selectorMatchesId supports scoped hf-HOST/hf-LEAF ids - sdkShadow: fix double data- prefix on attribute patch ops - sdkShadow: call onDomEditPersisted on SDK cutover success path - useSdkSession: fix compRef closure race on dispose; suppress self-write echo - App.tsx: pass domEditSaveTimestampRef to useSdkSession for echo suppression - examples: fix dead !comp.can(op) guard (needs .ok); remove stale id field Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/examples/headless-agent.ts | 5 +- packages/sdk/examples/react-embed.ts | 4 +- packages/sdk/examples/vanilla-editor.ts | 4 +- packages/sdk/src/adapters/fs.ts | 10 +++- packages/sdk/src/adapters/http.test.ts | 8 ++- packages/sdk/src/adapters/http.ts | 3 +- packages/sdk/src/engine/mutate.gsap.test.ts | 53 ++++++++++++++++--- packages/sdk/src/engine/mutate.test.ts | 16 +++--- packages/sdk/src/engine/mutate.ts | 13 ++++- packages/studio/src/App.tsx | 3 ++ .../studio/src/hooks/useDomEditCommits.ts | 11 ++-- packages/studio/src/hooks/useSdkSession.ts | 23 +++++--- packages/studio/src/utils/sdkShadow.test.ts | 14 +++++ packages/studio/src/utils/sdkShadow.ts | 15 +++--- 14 files changed, 139 insertions(+), 43 deletions(-) diff --git a/packages/sdk/examples/headless-agent.ts b/packages/sdk/examples/headless-agent.ts index 95f450c6a..6308ca86a 100644 --- a/packages/sdk/examples/headless-agent.ts +++ b/packages/sdk/examples/headless-agent.ts @@ -121,10 +121,7 @@ export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): P fromProperties: { opacity: 0, y: 30 }, } as const; const first = textEls[0]; - if ( - !first || - !comp.can({ type: "addGsapTween", target: first, id: "preflight", tween: probeTween }) - ) { + if (!first || !comp.can({ type: "addGsapTween", target: first, tween: probeTween }).ok) { return comp.serialize(); } diff --git a/packages/sdk/examples/react-embed.ts b/packages/sdk/examples/react-embed.ts index 69798276b..34db25ef3 100644 --- a/packages/sdk/examples/react-embed.ts +++ b/packages/sdk/examples/react-embed.ts @@ -98,12 +98,12 @@ export function addBounceIn(comp: Composition, targetId: string): string | null ease: "bounce.out", fromProperties: { y: 40, opacity: 0 }, } as const; - if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null; + if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null; return comp.addGsapTween(targetId, tween); } export function updateEase(comp: Composition, animationId: string, ease: string): void { - if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } })) return; + if (!comp.can({ type: "setGsapTween", animationId, properties: { ease } }).ok) return; comp.setGsapTween(animationId, { ease }); } diff --git a/packages/sdk/examples/vanilla-editor.ts b/packages/sdk/examples/vanilla-editor.ts index bcd5f472b..a2a1aa07a 100644 --- a/packages/sdk/examples/vanilla-editor.ts +++ b/packages/sdk/examples/vanilla-editor.ts @@ -113,7 +113,7 @@ export function addFadeIn(comp: Composition, targetId: string, delay = 0): strin ease: "power2.out", fromProperties: { opacity: 0 }, }; - if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null; + if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null; return comp.addGsapTween(targetId, tween); } @@ -130,7 +130,7 @@ export function addBounce( fromProperties: { y: 60, opacity: 0 }, ...overrides, }; - if (!comp.can({ type: "addGsapTween", target: targetId, id: "preflight", tween })) return null; + if (!comp.can({ type: "addGsapTween", target: targetId, tween }).ok) return null; return comp.addGsapTween(targetId, tween); } diff --git a/packages/sdk/src/adapters/fs.ts b/packages/sdk/src/adapters/fs.ts index 76d09edbe..7f13606ed 100644 --- a/packages/sdk/src/adapters/fs.ts +++ b/packages/sdk/src/adapters/fs.ts @@ -18,6 +18,7 @@ class FsAdapter implements PersistAdapter { private errorHandlers: Array<(e: PersistErrorEvent) => void> = []; private readonly inflightWrites = new Set>(); private versionCounter = 0; + private appendVersionQueue = Promise.resolve(); constructor(opts: FsAdapterOptions) { this.root = opts.root; @@ -109,7 +110,14 @@ class FsAdapter implements PersistAdapter { return join(this.root, ".hf-versions", path); } - private async appendVersion(path: string, content: string): Promise { + private appendVersion(path: string, content: string): Promise { + this.appendVersionQueue = this.appendVersionQueue.then(() => + this.doAppendVersion(path, content), + ); + return this.appendVersionQueue; + } + + private async doAppendVersion(path: string, content: string): Promise { const dir = this.versionsDir(path); await mkdir(dir, { recursive: true }); // Pad counter to 6 digits so lexicographic sort = insertion order within same ms. diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts index 19c39bb98..b3a8786ce 100644 --- a/packages/sdk/src/adapters/http.test.ts +++ b/packages/sdk/src/adapters/http.test.ts @@ -59,11 +59,17 @@ describe("read()", () => { expect(await adapter.read("missing.html")).toBeUndefined(); }); - it("returns undefined on non-ok response", async () => { + it("returns undefined on 404 response", async () => { stubFetch(() => ({ ok: false, status: 404 })); const adapter = createHttpAdapter({ projectFilesUrl: BASE }); expect(await adapter.read("gone.html")).toBeUndefined(); }); + + it("throws on 5xx server error", async () => { + stubFetch(() => ({ ok: false, status: 503 })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + await expect(adapter.read("comp.html")).rejects.toThrow("HTTP 503"); + }); }); // ── write() ─────────────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts index ed50a17fe..8bdad04be 100644 --- a/packages/sdk/src/adapters/http.ts +++ b/packages/sdk/src/adapters/http.ts @@ -30,7 +30,8 @@ class HttpAdapter implements PersistAdapter { async read(path: string): Promise { const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`; const res = await fetch(url); - if (!res.ok) return undefined; + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = (await res.json()) as { content?: string }; return typeof data.content === "string" ? data.content : undefined; } diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 4e2596714..5e19ca8d8 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -177,16 +177,17 @@ describe("addGsapTween", () => { expect(newScript).toContain("opacity: 1"); }); - it("returns EMPTY when no GSAP script", () => { + it("throws when no GSAP script block exists in composition", () => { const noScript = parseMutable( `
`, ); - const result = applyOp(noScript, { - type: "addGsapTween", - target: "hf-box", - tween: { method: "to", properties: { x: 1 } }, - }); - expect(result.forward).toHaveLength(0); + expect(() => + applyOp(noScript, { + type: "addGsapTween", + target: "hf-box", + tween: { method: "to", properties: { x: 1 } }, + }), + ).toThrow("No GSAP script block found"); }); }); @@ -477,3 +478,41 @@ window.__timelines["t"] = tl;`; expect(newScript).toContain("hf-stage"); }); }); + +// ─── GSAP ops on composition with no script block ──────────────────────────── + +const NO_SCRIPT_HTML = `
+
+
`.trim(); + +describe("GSAP ops on composition with no GSAP script block", () => { + function freshNoScript() { + return parseMutable(NO_SCRIPT_HTML); + } + + it("addGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { + type: "addGsapTween", + target: "hf-box", + tween: { method: "to", properties: { x: 100 } }, + }), + ).toThrow(); + }); + + it("setGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { + type: "setGsapTween", + animationId: "anim-1", + properties: { ease: "power2.out" }, + }), + ).toThrow(); + }); + + it("removeGsapTween throws instead of silent no-op", () => { + expect(() => + applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }), + ).toThrow(); + }); +}); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 89be75d2a..5323cdd50 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -389,14 +389,14 @@ describe("validateOp", () => { // ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ──────── describe("Phase 3b ops", () => { - it("applyOp returns EMPTY when no GSAP script is present", () => { - const result = applyOp(fresh(), { - type: "addGsapTween", - target: "hf-title", - tween: { method: "from", properties: { opacity: 0 } }, - }); - expect(result.forward).toHaveLength(0); - expect(result.inverse).toHaveLength(0); + it("applyOp throws when no GSAP script block is present", () => { + expect(() => + applyOp(fresh(), { + type: "addGsapTween", + target: "hf-title", + tween: { method: "from", properties: { opacity: 0 } }, + }), + ).toThrow("No GSAP script block found"); }); it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index d6015ce16..d18c622de 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -509,10 +509,15 @@ function handleSetVariableValue( // ─── GSAP selector helpers ─────────────────────────────────────────────────── function selectorMatchesId(selector: string, id: HfId): boolean { + const bareId = id.includes("/") ? id.split("/").pop()! : id; return ( selector === `[data-hf-id="${id}"]` || selector === `[data-hf-id='${id}']` || - selector === `#${id}` + selector === `#${id}` || + (bareId !== id && + (selector === `[data-hf-id="${bareId}"]` || + selector === `[data-hf-id='${bareId}']` || + selector === `#${bareId}`)) ); } @@ -585,6 +590,8 @@ function handleAddGsapTween( tween: GsapTweenSpec, ): MutationResult { const script = getGsapScript(parsed.document); + if (script === null) + throw new Error("No GSAP script block found. Use comp.can(op) to check first."); if (!script) return EMPTY; const extras: Record = {}; @@ -623,6 +630,8 @@ function handleSetGsapTween( properties: Partial, ): MutationResult { const script = getGsapScript(parsed.document); + if (script === null) + throw new Error("No GSAP script block found. Use comp.can(op) to check first."); if (!script) return EMPTY; const updates: Partial = {}; @@ -649,6 +658,8 @@ function handleSetGsapTween( function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult { const script = getGsapScript(parsed.document); + if (script === null) + throw new Error("No GSAP script block found. Use comp.can(op) to check first."); if (!script) return EMPTY; const newScript = removeAnimationFromScript(script, animationId); if (newScript === script) return EMPTY; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 783d18753..5c587e827 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -152,6 +152,9 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); + + const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); + useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 51931b012..d2fa24562 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -134,7 +134,6 @@ export function useDomEditCommits({ if (options?.shouldSave && !options.shouldSave()) return; const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const readResponse = await fetch( `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, ); @@ -146,9 +145,14 @@ export function useDomEditCommits({ if (typeof originalContent !== "string") { throw new Error(`Missing file contents for ${targetPath}`); } - if (options?.shouldSave && !options.shouldSave()) return; - + if ( + onTrySdkPersist && + (await onTrySdkPersist(selection, operations, originalContent, targetPath)) + ) { + onDomEditPersisted?.(selection, operations); + return; + } const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); @@ -162,7 +166,6 @@ export function useDomEditCommits({ // handler suppresses the reload even if the event arrives before the // response (the server writes the file and emits SSE during the fetch). domEditSaveTimestampRef.current = Date.now(); - const patchResponse = await fetch( `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 7a3fbf1ee..e99ede973 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import type { Composition } from "@hyperframes/sdk"; @@ -27,9 +28,12 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * 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. */ +const SELF_WRITE_SUPPRESS_MS = 2000; + export function useSdkSession( projectId: string | null, activeCompPath: string | null, + domEditSaveTimestampRef?: MutableRefObject, ): Composition | null { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -38,9 +42,14 @@ export function useSdkSession( useEffect(() => { if (!activeCompPath) return; const handler = (payload?: unknown) => { - if (shouldReloadSdkSession(payload, activeCompPath)) { - setReloadToken((t) => t + 1); - } + if (!shouldReloadSdkSession(payload, activeCompPath)) return; + // Suppress reload triggered by our own SDK cutover write. + if ( + domEditSaveTimestampRef && + Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS + ) + return; + setReloadToken((t) => t + 1); }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -50,6 +59,7 @@ export function useSdkSession( const es = new EventSource("/api/events"); es.addEventListener("file-change", handler); return () => es.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeCompPath]); // ── Open / re-open the session ── @@ -60,7 +70,7 @@ export function useSdkSession( } let cancelled = false; - let comp: Composition | null = null; + const compRef = { current: null as Composition | null }; const adapter = createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}`, @@ -69,7 +79,7 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - comp = await openComposition(content, { + const comp = await openComposition(content, { persist: adapter, persistPath: activeCompPath, }); @@ -81,6 +91,7 @@ export function useSdkSession( comp.dispose(); return; } + compRef.current = comp; setSession(comp); }) .catch(() => { @@ -89,7 +100,7 @@ export function useSdkSession( return () => { cancelled = true; - const c = comp; + const c = compRef.current; if (c) void c.flush().finally(() => c.dispose()); }; }, [projectId, activeCompPath, reloadToken]); diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index 637ab9ba5..7f367e62a 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -42,6 +42,20 @@ describe("patchOpsToSdkEditOps", () => { }); }); + it("does not double-prefix attribute op whose property already starts with data-", () => { + const ops: PatchOperation[] = [ + { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, + ]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "setAttribute", + target: "hf-box", + name: "data-hf-studio-path-offset", + value: "true", + }); + }); + it("maps html-attribute op to setAttribute without prefix", () => { const ops: PatchOperation[] = [ { type: "html-attribute", property: "contenteditable", value: "true" }, diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 6167039ad..7a8d799d8 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -38,7 +38,7 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO result.push({ type: "setAttribute", target: hfId, - name: `data-${op.property}`, + name: op.property.startsWith("data-") ? op.property : `data-${op.property}`, value: op.value, }); } else if (op.type === "html-attribute") { @@ -105,11 +105,14 @@ const OP_FIELD_RESOLVERS: Record = { actual: flat.styles[op.property] ?? null, }), "text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }), - attribute: (op, flat) => ({ - property: `data-${op.property}`, - expected: op.value ?? null, - actual: flat.attrs[`data-${op.property}`] ?? null, - }), + attribute: (op, flat) => { + const attrName = op.property.startsWith("data-") ? op.property : `data-${op.property}`; + return { + property: attrName, + expected: op.value ?? null, + actual: flat.attrs[attrName] ?? null, + }; + }, "html-attribute": (op, flat) => ({ property: op.property, expected: op.value ?? null, From 47db28f8c28e472f95e9c55993c24e5a2dfda43f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 19:18:28 -0700 Subject: [PATCH 4/8] fix(sdk,studio): 15 code-review correctness fixes from second stage 7 review Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/src/adapters/fs.ts | 8 +++--- packages/sdk/src/adapters/http.test.ts | 15 +++++++++++ packages/sdk/src/adapters/http.ts | 7 ++++- packages/sdk/src/engine/apply-patches.ts | 6 ++--- packages/sdk/src/engine/mutate.gsap.test.ts | 28 ++++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 5 +++- packages/sdk/src/session.subcomp.test.ts | 18 ++++++++++++- packages/sdk/src/session.ts | 7 ++++- packages/studio/src/utils/sdkCutover.test.ts | 24 +++++++++++++++++ packages/studio/src/utils/sdkCutover.ts | 9 ++++++- packages/studio/src/utils/sdkShadow.ts | 2 +- 11 files changed, 116 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/adapters/fs.ts b/packages/sdk/src/adapters/fs.ts index 7f13606ed..b6a6cdd83 100644 --- a/packages/sdk/src/adapters/fs.ts +++ b/packages/sdk/src/adapters/fs.ts @@ -62,7 +62,7 @@ class FsAdapter implements PersistAdapter { } async flush(): Promise { - await Promise.all([...this.inflightWrites]); + await Promise.all([...this.inflightWrites, this.appendVersionQueue]); } async listVersions(path: string): Promise { @@ -111,9 +111,9 @@ class FsAdapter implements PersistAdapter { } private appendVersion(path: string, content: string): Promise { - this.appendVersionQueue = this.appendVersionQueue.then(() => - this.doAppendVersion(path, content), - ); + this.appendVersionQueue = this.appendVersionQueue + .then(() => this.doAppendVersion(path, content)) + .catch(() => {}); return this.appendVersionQueue; } diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts index b3a8786ce..fbe52685c 100644 --- a/packages/sdk/src/adapters/http.test.ts +++ b/packages/sdk/src/adapters/http.test.ts @@ -70,6 +70,21 @@ describe("read()", () => { const adapter = createHttpAdapter({ projectFilesUrl: BASE }); await expect(adapter.read("comp.html")).rejects.toThrow("HTTP 503"); }); + + it("returns undefined when 200 response body is not valid JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + }), + ); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + await expect(adapter.read("comp.html")).resolves.toBeUndefined(); + }); }); // ── write() ─────────────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts index 8bdad04be..fe57e0023 100644 --- a/packages/sdk/src/adapters/http.ts +++ b/packages/sdk/src/adapters/http.ts @@ -32,7 +32,12 @@ class HttpAdapter implements PersistAdapter { const res = await fetch(url); if (res.status === 404) return undefined; if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as { content?: string }; + let data: { content?: string }; + try { + data = (await res.json()) as { content?: string }; + } catch { + return undefined; + } return typeof data.content === "string" ? data.content : undefined; } diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index 9bff69e78..24fdd9016 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -18,7 +18,7 @@ import { setGsapScript, setStyleSheet, } from "./model.js"; -import { keyToPath } from "./patches.js"; +import { keyToPath, gsapScriptPath, styleSheetPath } from "./patches.js"; // ─── Path parser ──────────────────────────────────────────────────────────── @@ -70,8 +70,8 @@ function parsePath(path: string): ParsedPath | null { const metaM = /^\/metadata\/(.+)$/.exec(path); if (metaM) return { type: "metadata", field: metaM[1] }; - if (path === "/script/gsap") return { type: "script" }; - if (path === "/style/css") return { type: "stylesheet" }; + if (path === gsapScriptPath()) return { type: "script" }; + if (path === styleSheetPath()) return { type: "stylesheet" }; return null; } diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 5e19ca8d8..88ff253c1 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -189,6 +189,23 @@ describe("addGsapTween", () => { }), ).toThrow("No GSAP script block found"); }); + + it("uses bare leaf id in selector when target is a scoped id", () => { + const html = `
+
+ +
`.trim(); + const parsed = parseMutable(html); + const result = applyOp(parsed, { + type: "addGsapTween", + target: "hf-stage/hf-box", + tween: { method: "to", properties: { x: 100 } }, + }); + expect(result.forward.length).toBeGreaterThan(0); + const newScript = String(result.forward[0]?.value ?? ""); + expect(newScript).toContain("hf-box"); + expect(newScript).not.toContain("hf-stage/hf-box"); + }); }); // ─── Tween op test helpers ──────────────────────────────────────────────────── @@ -515,4 +532,15 @@ describe("GSAP ops on composition with no GSAP script block", () => { applyOp(freshNoScript(), { type: "removeGsapTween", animationId: "anim-1" }), ).toThrow(); }); + + it("addGsapKeyframe throws when script element is null", () => { + expect(() => + applyOp(freshNoScript(), { + type: "addGsapKeyframe", + animationId: "a1", + percentage: 0, + value: { opacity: 0 }, + }), + ).toThrow("No GSAP script block found"); + }); }); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index d18c622de..6b615dda5 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -604,8 +604,9 @@ function handleAddGsapTween( ? ((tween.toProperties ?? {}) as Record) : ((tween.toProperties ?? tween.properties ?? {}) as Record); + const selectorId = target.includes("/") ? target.split("/").pop()! : target; const animation: Omit = { - targetSelector: `[data-hf-id="${target}"]`, + targetSelector: `[data-hf-id="${selectorId}"]`, method: tween.method, position: tween.position ?? 0, ...(tween.duration !== undefined ? { duration: tween.duration } : {}), @@ -716,6 +717,8 @@ function handleAddGsapKeyframe( value: Record, ): MutationResult { const script = getGsapScript(parsed.document); + if (script === null) + throw new Error("No GSAP script block found. Use comp.can(op) to check first."); if (!script) return EMPTY; const newScript = addKeyframeToScript( script, diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts index 91a99e0dd..ded0c69ff 100644 --- a/packages/sdk/src/session.subcomp.test.ts +++ b/packages/sdk/src/session.subcomp.test.ts @@ -340,7 +340,7 @@ describe("find({ composition })", () => { const ids = comp.find({ composition: "hf-host" }); expect(ids).toContain("hf-host/hf-leaf"); expect(ids).not.toContain("hf-outer"); - expect(ids).not.toContain("hf-host"); // host itself is in parent scope + expect(ids).toContain("hf-host"); // host element is included in its own composition scope }); it("returns empty array for unknown host id", async () => { @@ -351,6 +351,22 @@ describe("find({ composition })", () => { expect(comp.find({ composition: "hf-no-such" })).toEqual([]); }); + it("find({ composition }) includes the host element itself", async () => { + const html = inlinedHtml(` +
+
+

inside

+
+

outside

+
+ `); + const comp = await openComposition(html); + const ids = comp.find({ composition: "hf-host" }); + expect(ids).toContain("hf-host"); + expect(ids).toContain("hf-host/hf-leaf"); + expect(ids).not.toContain("hf-outer"); + }); + it("can combine composition filter with other query fields", async () => { const html = inlinedHtml(`
diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 149b770b6..8473c60e4 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -188,7 +188,12 @@ class CompositionImpl implements Composition { if (query.text && !el.text?.includes(query.text)) return false; if (query.name && el.attributes["data-name"] !== query.name) return false; if (query.track !== undefined && el.trackIndex !== query.track) return false; - if (query.composition && !el.scopedId.startsWith(`${query.composition}/`)) return false; + if ( + query.composition && + el.scopedId !== query.composition && + !el.scopedId.startsWith(`${query.composition}/`) + ) + return false; return true; }) .map((el) => el.scopedId) diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index d2c8cfb9f..562419903 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -211,6 +211,30 @@ describe("sdkCutoverPersist", () => { }); }); + it("passes caller label to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + label: "Resize layer box", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ label: "Resize layer box" }), + ); + }); + + it("passes caller coalesceKey to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + coalesceKey: "my-key", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ coalesceKey: "my-key" }), + ); + }); + it("returns false and does not throw on dispatch error", async () => { const deps = makeDeps(); const session = makeSession(true); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index a6191be94..f5afd754c 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -43,6 +43,11 @@ interface CutoverDeps { domEditSaveTimestampRef: MutableRefObject; } +interface CutoverOptions { + label?: string; + coalesceKey?: string; +} + export async function sdkCutoverPersist( selection: DomEditSelection, ops: PatchOperation[], @@ -50,6 +55,7 @@ export async function sdkCutoverPersist( targetPath: string, sdkSession: Composition | null | undefined, deps: CutoverDeps, + options?: CutoverOptions, ): Promise { if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) return false; @@ -65,8 +71,9 @@ export async function sdkCutoverPersist( deps.domEditSaveTimestampRef.current = Date.now(); await deps.writeProjectFile(targetPath, after); await deps.editHistory.recordEdit({ - label: "Edit layer", + label: options?.label ?? "Edit layer", kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), files: { [targetPath]: { before: originalContent, after } }, }); deps.reloadPreview(); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 7a8d799d8..d9c62c240 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -98,7 +98,7 @@ function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot { type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields; -const OP_FIELD_RESOLVERS: Record = { +const OP_FIELD_RESOLVERS: Record = { "inline-style": (op, flat) => ({ property: op.property, expected: op.value, From 7c2d9d5fccc802c01b3c0e14093f63eadb713e0b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 21:26:55 -0700 Subject: [PATCH 5/8] fix(studio): wrap sdk cutover dispatch loop in session.batch() for atomicity On multi-op payloads, a mid-loop dispatch failure left the SDK session partially mutated. batch() rolls back all ops on throw. Adds: batch-is-called test, multi-op-throw test, GSAP script preservation integration test (linkedom round-trip verified). Co-Authored-By: Claude Sonnet 4.6 --- packages/studio/src/utils/sdkCutover.test.ts | 79 ++++++++++++++++++++ packages/studio/src/utils/sdkCutover.ts | 8 +- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 562419903..489113cbe 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; import type { PatchOperation } from "./sourcePatcher"; import type { MutableRefObject } from "react"; @@ -96,6 +98,7 @@ describe("sdkCutoverPersist", () => { getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), dispatch: vi.fn(), serialize: vi.fn().mockReturnValue(""), + batch: vi.fn((fn: () => void) => fn()), }) as unknown as Parameters[4]; it("returns false when session is null", async () => { @@ -253,4 +256,80 @@ describe("sdkCutoverPersist", () => { expect(result).toBe(false); expect(deps.reloadPreview).not.toHaveBeenCalled(); }); + + it("wraps all dispatches in session.batch() for atomic rollback", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect( + (session as unknown as { batch: ReturnType }).batch, + ).toHaveBeenCalledOnce(); + }); + + it("returns false when second dispatch throws (batch prevents partial mutation)", async () => { + // inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches. + const deps = makeDeps(); + const session = makeSession(true); + let callCount = 0; + (session!.dispatch as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error("2nd op failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), textOp("hello")], + "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 = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + it("preserves GSAP +`; + const comp = await openComposition(html, { persist: createMemoryAdapter() }); + const deps = makeDeps(); + const sel = { hfId: "hf-layer" } as never; + const result = await sdkCutoverPersist( + sel, + [{ type: "inline-style", property: "color", value: "red" }], + html, + "/comp.html", + comp, + deps, + ); + expect(result).toBe(true); + const written = (deps.writeProjectFile as ReturnType).mock + .calls[0]?.[1] as string; + expect(written).toContain("data-hf-gsap"); + expect(written).toContain('data-position-mode="relative"'); + expect(written).toContain("gsap.timeline()"); + }); }); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index f5afd754c..6bb3afee0 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -64,9 +64,11 @@ export async function sdkCutoverPersist( if (!hfId) return false; if (!sdkSession.getElement(hfId)) return false; try { - for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { - sdkSession.dispatch(editOp); - } + sdkSession.batch(() => { + for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { + sdkSession.dispatch(editOp); + } + }); const after = sdkSession.serialize(); deps.domEditSaveTimestampRef.current = Date.now(); await deps.writeProjectFile(targetPath, after); From f04736157816f7b5c0920867fe413058084cd85d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 23:15:37 -0700 Subject: [PATCH 6/8] fix(studio): document SELF_WRITE_SUPPRESS_MS heuristic; fix pre-existing TS errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer noted the 2 s suppress window is a footgun. Add a comment explaining the trade-off (short = echo fires anyway; long = masks real edits) and naming the long-term fix (sequence number / content hash on the persist event). Also fix three pre-existing issues in useDomEditCommits.ts on this branch: - PatchOperation was used in UseDomEditCommitsParams but not imported - onTrySdkPersist was called in persistDomEditOperations but missing from both the interface and the function's destructure pattern - onTrySdkPersist missing from useCallback deps (react-hooks/exhaustive-deps) Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/hooks/useDomEditCommits.ts | 9 +++++++++ packages/studio/src/hooks/useSdkSession.ts | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index d2fa24562..20218328b 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -78,6 +78,13 @@ export interface UseDomEditCommitsParams { ) => Promise; /** Stage 7 Step 3b: called after a successful server-side element patch. */ onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => 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, + ) => Promise; } export function useDomEditCommits({ @@ -99,6 +106,7 @@ export function useDomEditCommits({ refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted, + onTrySdkPersist, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -238,6 +246,7 @@ export function useDomEditCommits({ reloadPreview, showToast, onDomEditPersisted, + onTrySdkPersist, ], ); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index e99ede973..c75632479 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -28,6 +28,12 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * 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. */ +// 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. +// Footgun: if 2 s is too short (slow FS / network) the reload fires anyway; +// if too long it masks a legitimate external edit. The long-term shape is a +// sequence number or content hash threaded through the persist event so the +// comparison is exact rather than time-based. const SELF_WRITE_SUPPRESS_MS = 2000; export function useSdkSession( From c2d6a51d4927fe06c11ad4445c6081418a9dd6a0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 23:30:12 -0700 Subject: [PATCH 7/8] fix(studio): remove duplicate useSdkSession call from App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of af1d91bf8 (dispose race fix) added a second useSdkSession call at the original insertion point from s7step1. s7step3c already had the correct call (with domEditSaveTimestampRef for self-write suppression) at line 156. Remove the duplicate. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5c587e827..efe636e5a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -269,7 +269,6 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); - const sdkSession = useSdkSession(projectId, activeCompPath); const domEditSession = useDomEditSession({ projectId, activeCompPath, From 859cb40671ae35c8555978399dee6ba396a46506 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 02:26:52 -0700 Subject: [PATCH 8/8] =?UTF-8?q?feat(sdk-playground):=20stage=207=20?= =?UTF-8?q?=E2=80=94=20HTTP=20adapter=20+=20setSelection=20(#1455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sdk-playground): accurate README with stage coverage and CanResult * feat(sdk-playground): showcase stage 4 — canUndo/canRedo, removeElement cascade - header undo/redo buttons disabled when canUndo()/canRedo() returns false - History/inspect op section shows live canUndo/canRedo badges (update on every patch) - removeElement logs override-set after removal to demonstrate cascade + orphan cleanup - README: stage 4 row updated to full coverage; Danger section + Ops table annotated - mutate.ts: add fallow-ignore-file code-duplication (structural handler boilerplate) Co-Authored-By: Claude Sonnet 4.6 * feat(sdk-playground): stage 5+6 — adapter exports, scoped ids, find(composition) Co-Authored-By: Claude Sonnet 4.6 * feat(sdk-playground): stage 7 — HTTP adapter + setSelection, update README Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/sdk-playground/README.md | 87 +++++--- packages/sdk-playground/index.html | 11 + packages/sdk-playground/src/main.ts | 257 ++++++++++++++++++++--- packages/sdk-playground/vite.config.ts | 53 +++++ packages/sdk/src/engine/mutate.ts | 1 + packages/sdk/src/session.subcomp.test.ts | 100 +++++---- 6 files changed, 395 insertions(+), 114 deletions(-) diff --git a/packages/sdk-playground/README.md b/packages/sdk-playground/README.md index eb8dd1031..98e9c91ed 100644 --- a/packages/sdk-playground/README.md +++ b/packages/sdk-playground/README.md @@ -10,75 +10,102 @@ bun run --cwd packages/sdk-playground dev Serves at `http://localhost:5173`. On first load it reads `packages/sdk-playground/composition.html` from disk (if present) or falls back to a built-in demo composition. +## Stage coverage + +The playground exercises the full SDK surface end-to-end in a real browser against a +file-backed persist adapter: + +| SDK stage | What is exercised | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stage 3a — Session API | `openComposition`, `dispatch`, `undo`/`redo`, `batch`, `on('patch')`, `on('selectionchange')`, `on('persist:error')`, `flush` | +| Stage 3b — GSAP engine | `addGsapTween`, `setGsapTween`, `removeGsapTween`, `addLabel`, `removeLabel`, `setClassStyle`, `setTiming` (GSAP-script sync) | +| Stage 4 | `canUndo()`/`canRedo()` (live button + Ops badge), `removeElement` GSAP cascade (logs override-set after cascade to confirm orphan cleanup), `can()` → `CanResult`, `getOverrides()`, `selection()` proxy, `find()`, `setVariableValue` | +| Stage 5 | `createHeadlessAdapter()` and `createMemoryAdapter()` exported from package root; `FsAdapter` — file-backed persistence with version history; `FileAdapter` — browser fetch adapter; `PlaygroundPreview` — concrete `PreviewAdapter` impl | +| Stage 6 | Scoped ids (`hf-HOST/hf-LEAF`), `find({ composition })` filter, ops targeting sub-composition elements via `comp.setStyle("hf-card/hf-card-title", styles)` | +| Stage 7 | `createHttpAdapter({ projectFilesUrl })` — REST-backed persist adapter (read/write via fetch); `comp.setSelection(ids)` — programmatic selection that fires `selectionchange` without going through the preview iframe | + ## Features ### File persistence Composition state is persisted to `packages/sdk-playground/composition.html` via a Vite dev-server plugin backed by `@hyperframes/sdk/adapters/fs`. Every save writes a timestamped snapshot to `.hf-versions/composition.html/` (capped at 20). Reload the page and your last state is restored. +The Stage 7 HTTP Adapter section demos `createHttpAdapter` against the same underlying file via a matching REST endpoint the Vite plugin exposes at `/api/project/files/`. + ### Preview iframe Full composition rendered in a sandboxed `