From 450267ea53ef3c3bc8d1d09546f71b467336cb8b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 16:05:01 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(studio):=20stage=207=20step=203b=20?= =?UTF-8?q?=E2=80=94=20SDK=20shadow=20dispatch=20parity=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire onDomEditPersisted callback from useDomEditCommits into useDomEditSession, calling reportShadowDispatch (flag-gated via VITE_STUDIO_SDK_SHADOW_ENABLED) to dispatch equivalent SDK ops alongside the server patch path and emit sdk_shadow_dispatch telemetry with mismatch details. Co-Authored-By: Claude Opus 4.8 --- packages/studio/src/App.tsx | 1 + .../editor/manualEditingAvailability.ts | 9 + .../studio/src/hooks/useDomEditCommits.ts | 9 +- .../studio/src/hooks/useDomEditSession.ts | 8 + packages/studio/src/utils/sdkShadow.test.ts | 123 ++++++++++++ packages/studio/src/utils/sdkShadow.ts | 189 ++++++++++++++++++ 6 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/utils/sdkShadow.test.ts create mode 100644 packages/studio/src/utils/sdkShadow.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 67354accc1..783d18753d 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -301,6 +301,7 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, + sdkSession, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 7c702ac5f5..e2d74d8adb 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -88,4 +88,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED; +// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK +// session alongside the server patch path and logs mismatches via telemetry. +// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true. +export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_SDK_SHADOW_ENABLED"], + false, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 2137a82fd8..53cff7710b 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -40,8 +40,6 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } return `Couldn't save edit: ${body.error}${suffix}`; } -// ── Types ── - interface RecordEditInput { label: string; kind: EditHistoryKind; @@ -77,10 +75,10 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; + /** Stage 7 Step 3b: called after a successful server-side element patch. */ + onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; } -// ── Hook ── - export function useDomEditCommits({ activeCompPath, previewIframeRef, @@ -99,6 +97,7 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + onDomEditPersisted, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -220,6 +219,7 @@ export function useDomEditCommits({ coalesceKey: options?.coalesceKey, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); + onDomEditPersisted?.(selection, operations); if (!options?.skipRefresh) { reloadPreview(); @@ -233,6 +233,7 @@ export function useDomEditCommits({ domEditSaveTimestampRef, reloadPreview, showToast, + onDomEditPersisted, ], ); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index c6aa36a19f..ae27972454 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,3 +1,4 @@ +import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; +import { reportShadowDispatch } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -58,6 +60,8 @@ export interface UseDomEditSessionParams { openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; + /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */ + sdkSession?: Composition | null; } // ── Hook ── @@ -96,6 +100,7 @@ export function useDomEditSession({ openSourceForSelection, selectSidebarTab, getSidebarTab, + sdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -227,6 +232,9 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, + onDomEditPersisted: sdkSession + ? (sel, ops) => reportShadowDispatch(sdkSession, sel, ops) + : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts new file mode 100644 index 0000000000..925ab4d978 --- /dev/null +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow"; +import type { PatchOperation } from "./sourcePatcher"; +import { openComposition } from "@hyperframes/sdk"; + +const BASE_HTML = /* html */ ` + +
Hello
+`; + +describe("patchOpsToSdkEditOps", () => { + it("maps inline-style ops to a single setStyle EditOp", () => { + const ops: PatchOperation[] = [ + { type: "inline-style", property: "color", value: "#00f" }, + { type: "inline-style", property: "opacity", value: "0.5" }, + ]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "setStyle", + target: "hf-box", + styles: { color: "#00f", opacity: "0.5" }, + }); + }); + + it("maps text-content op to setText EditOp", () => { + const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" }); + }); + + it("maps attribute op to setAttribute with data- prefix", () => { + const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "setAttribute", + target: "hf-box", + name: "data-name", + value: "hero", + }); + }); + + it("maps html-attribute op to setAttribute without prefix", () => { + const ops: PatchOperation[] = [ + { type: "html-attribute", property: "contenteditable", value: "true" }, + ]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "setAttribute", + target: "hf-box", + name: "contenteditable", + value: "true", + }); + }); + + it("handles null value for attribute removal", () => { + const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }]; + const result = patchOpsToSdkEditOps("hf-box", ops); + expect(result[0]).toEqual({ + type: "setAttribute", + target: "hf-box", + name: "hidden", + value: null, + }); + }); + + it("returns empty array for unknown op types", () => { + const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[]; + expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0); + }); +}); + +describe("sdkShadowDispatch (integration)", () => { + it("applies ops and returns no mismatches when SDK matches expected values", async () => { + const { sdkShadowDispatch } = await import("./sdkShadow"); + const session = await openComposition(BASE_HTML); + + const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; + const result = sdkShadowDispatch(session, "hf-box", ops); + + expect(result.dispatched).toBe(true); + expect(result.mismatches).toHaveLength(0); + expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f"); + }); + + it("returns dispatched:false when hfId not found in session", async () => { + const { sdkShadowDispatch } = await import("./sdkShadow"); + const session = await openComposition(BASE_HTML); + + const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; + const result = sdkShadowDispatch(session, "hf-missing", ops); + + expect(result.dispatched).toBe(false); + expect(result.mismatches).toHaveLength(1); + expect(result.mismatches[0]).toMatchObject({ + kind: "element_not_found", + hfId: "hf-missing", + }); + }); + + it("applies text op and reads back via session.getElement", async () => { + const { sdkShadowDispatch } = await import("./sdkShadow"); + const session = await openComposition(BASE_HTML); + + const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }]; + sdkShadowDispatch(session, "hf-box", ops); + + expect(session.getElement("hf-box")?.text).toBe("Updated"); + }); + + it("applies attribute op and reads back via session.getElement", async () => { + const { sdkShadowDispatch } = await import("./sdkShadow"); + const session = await openComposition(BASE_HTML); + + const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; + sdkShadowDispatch(session, "hf-box", ops); + + expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero"); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts new file mode 100644 index 0000000000..8caa2a4931 --- /dev/null +++ b/packages/studio/src/utils/sdkShadow.ts @@ -0,0 +1,189 @@ +/** + * SDK shadow dispatch utilities for Stage 7 Step 3b. + * + * Shadow mode keeps the server patch path authoritative while also dispatching + * the equivalent op to the SDK session, then compares the result to detect + * addressing gaps (blocker E: no-hf-id elements) and serialization drift + * (blocker B: linkedom whole-doc serialize). Results are reported as structured + * mismatches for telemetry — no user-visible change. + */ + +import type { Composition } from "@hyperframes/sdk"; +import type { EditOp } from "@hyperframes/sdk"; +import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "./studioTelemetry"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { PatchOperation } from "./sourcePatcher"; + +// ─── Op mapping ────────────────────────────────────────────────────────────── + +/** + * Map Studio PatchOperations for a given hf-id to SDK EditOps. + * + * Multiple inline-style ops are coalesced into a single setStyle (SDK batches + * style changes naturally). One SDK op is emitted per non-style op. + */ +export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { + const result: EditOp[] = []; + const styles: Record = {}; + let hasStyles = false; + + for (const op of ops) { + if (op.type === "inline-style") { + styles[op.property] = op.value; + hasStyles = true; + } else if (op.type === "text-content") { + result.push({ type: "setText", target: hfId, value: op.value ?? "" }); + } else if (op.type === "attribute") { + result.push({ + type: "setAttribute", + target: hfId, + name: `data-${op.property}`, + value: op.value, + }); + } else if (op.type === "html-attribute") { + result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); + } + // unknown op types produce no SDK op + } + + if (hasStyles) { + result.unshift({ type: "setStyle", target: hfId, styles }); + } + + return result; +} + +// ─── Shadow result types ────────────────────────────────────────────────────── + +export interface SdkShadowMismatch { + kind: "element_not_found" | "value_mismatch"; + hfId: string; + property?: string; + expected?: string | null; + actual?: string | null | undefined; +} + +export interface SdkShadowResult { + /** False if the element was not found in the SDK session. */ + dispatched: boolean; + mismatches: SdkShadowMismatch[]; +} + +// ─── Shadow dispatch ────────────────────────────────────────────────────────── + +type ElementSnapshot = ReturnType; +type OpFields = { + property: string; + expected: string | null | undefined; + actual: string | null | undefined; +}; + +type FlatSnapshot = { + styles: Record; + attrs: Record; + text: string | null; +}; + +function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot { + return { + styles: snap?.inlineStyles ?? {}, + attrs: Object.fromEntries( + Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]), + ), + text: snap?.text ?? null, + }; +} + +type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields; + +const OP_FIELD_RESOLVERS: Record = { + "inline-style": (op, flat) => ({ + property: op.property, + expected: op.value, + 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, + }), + "html-attribute": (op, flat) => ({ + property: op.property, + expected: op.value ?? null, + actual: flat.attrs[op.property] ?? null, + }), +}; + +function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null { + return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null; +} + +function checkOpParity( + op: PatchOperation, + flat: FlatSnapshot, + hfId: string, +): SdkShadowMismatch | null { + const fields = resolveOpFields(op, flat); + if (!fields || fields.actual === fields.expected) return null; + return { kind: "value_mismatch", hfId, ...fields }; +} + +/** + * Dispatch PatchOperations to the SDK session and return a parity report. + * + * If the element is not found by hfId, returns dispatched:false with a + * element_not_found mismatch (signals blocker E — element has no hf-id or + * SDK can't address it). + * + * On success, verifies that the SDK element snapshot reflects the applied + * values. Value mismatches indicate serialization or normalization drift. + */ + +export function sdkShadowDispatch( + session: Composition, + hfId: string, + ops: PatchOperation[], +): SdkShadowResult { + if (!session.getElement(hfId)) { + return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; + } + for (const op of patchOpsToSdkEditOps(hfId, ops)) { + session.dispatch(op); + } + const flat = flattenSnapshot(session.getElement(hfId)); + const mismatches = ops + .map((op) => checkOpParity(op, flat, hfId)) + .filter((m): m is SdkShadowMismatch => m !== null); + return { dispatched: true, mismatches }; +} + +// ─── Telemetry reporting ────────────────────────────────────────────────────── + +/** + * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. + * No-op when STUDIO_SDK_SHADOW_ENABLED is false. + */ +export function reportShadowDispatch( + session: Composition, + selection: DomEditSelection, + ops: PatchOperation[], +): void { + if (!STUDIO_SDK_SHADOW_ENABLED) return; + const hfId = selection.hfId; + if (!hfId) { + trackStudioEvent("sdk_shadow_dispatch", { + dispatched: false, + reason: "no_hf_id", + mismatchCount: 0, + }); + return; + } + const result = sdkShadowDispatch(session, hfId, ops); + trackStudioEvent("sdk_shadow_dispatch", { + dispatched: result.dispatched, + mismatchCount: result.mismatches.length, + mismatches: JSON.stringify(result.mismatches), + }); +} From 6c1c379d61de1b875eab0e1b08f87029edf7ddb4 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 21:55:45 -0700 Subject: [PATCH 02/11] fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the dispatch loop in try/catch so a throwing SDK dispatch never propagates to Studio UX. Returns dispatched:false with kind="dispatch_error" and the error message for telemetry. One new TDD test (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 --- packages/studio/src/utils/sdkShadow.test.ts | 23 +++++++++++++++++++++ packages/studio/src/utils/sdkShadow.ts | 14 ++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index 925ab4d978..637ab9ba53 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -120,4 +120,27 @@ describe("sdkShadowDispatch (integration)", () => { expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero"); }); + + it("returns dispatch_error when dispatch throws — does not propagate", async () => { + const { sdkShadowDispatch } = await import("./sdkShadow"); + const session = await openComposition(BASE_HTML); + // Poison dispatch so it throws on any call + session.dispatch = () => { + throw new Error("sdk internal error"); + }; + + const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }]; + let result: ReturnType | undefined; + expect(() => { + result = sdkShadowDispatch(session, "hf-box", ops); + }).not.toThrow(); + + expect(result!.dispatched).toBe(false); + expect(result!.mismatches).toHaveLength(1); + expect(result!.mismatches[0]).toMatchObject({ + kind: "dispatch_error", + hfId: "hf-box", + error: expect.stringContaining("sdk internal error"), + }); + }); }); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 8caa2a4931..19df636bf8 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -57,11 +57,12 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO // ─── Shadow result types ────────────────────────────────────────────────────── export interface SdkShadowMismatch { - kind: "element_not_found" | "value_mismatch"; + kind: "element_not_found" | "value_mismatch" | "dispatch_error"; hfId: string; property?: string; expected?: string | null; actual?: string | null | undefined; + error?: string; } export interface SdkShadowResult { @@ -149,8 +150,15 @@ export function sdkShadowDispatch( if (!session.getElement(hfId)) { return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; } - for (const op of patchOpsToSdkEditOps(hfId, ops)) { - session.dispatch(op); + try { + for (const op of patchOpsToSdkEditOps(hfId, ops)) { + session.dispatch(op); + } + } catch (err) { + return { + dispatched: false, + mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }], + }; } const flat = flattenSnapshot(session.getElement(hfId)); const mismatches = ops From fb249f69b22fcb84f43de8b0b20fa3443bb5c804 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 23:21:18 -0700 Subject: [PATCH 03/11] fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the shadow dispatch loop in session.batch() so a mid-loop throw cannot leave the SDK session in a partially-applied state. Without the batch boundary, one failing op would update some elements but not others, diverging the shadow session from the real one. Rename reportShadowDispatch → runShadowDispatch to eliminate the misleading 'report' prefix — the function mutates the SDK session, it is not read-only. Update the only caller (useDomEditSession). Add missing PatchOperation import to useDomEditCommits (the type was already used in the onDomEditPersisted interface but never imported). Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/hooks/useDomEditCommits.ts | 1 + packages/studio/src/hooks/useDomEditSession.ts | 4 ++-- packages/studio/src/utils/sdkShadow.ts | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 53cff7710b..51931b012b 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -9,6 +9,7 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { PersistDomEditOperations } from "./domEditCommitTypes"; +import type { PatchOperation } from "../utils/sourcePatcher"; import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index ae27972454..848fc9f5f5 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { reportShadowDispatch } from "../utils/sdkShadow"; +import { runShadowDispatch } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -233,7 +233,7 @@ export function useDomEditSession({ refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted: sdkSession - ? (sel, ops) => reportShadowDispatch(sdkSession, sel, ops) + ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) : undefined, }); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 19df636bf8..6167039ad1 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -151,9 +151,10 @@ export function sdkShadowDispatch( return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; } try { - for (const op of patchOpsToSdkEditOps(hfId, ops)) { - session.dispatch(op); - } + const sdkOps = patchOpsToSdkEditOps(hfId, ops); + session.batch(() => { + for (const op of sdkOps) session.dispatch(op); + }); } catch (err) { return { dispatched: false, @@ -171,9 +172,10 @@ export function sdkShadowDispatch( /** * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. - * No-op when STUDIO_SDK_SHADOW_ENABLED is false. + * Despite the telemetry focus, this function does mutate the SDK session — it + * is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false. */ -export function reportShadowDispatch( +export function runShadowDispatch( session: Composition, selection: DomEditSelection, ops: PatchOperation[], From 8788bf187d1efab8447266af17c90e2fa2e13bad Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 16:36:38 -0700 Subject: [PATCH 04/11] =?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 e2d74d8adb..8d8a49ba19 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 0000000000..f8cb005223 --- /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 0000000000..c7438a2200 --- /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 0000000000..2156f0bd7b --- /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 05/11] =?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 f8cb005223..040d83b3b0 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 c7438a2200..d2c8cfb9fc 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 2156f0bd7b..a6191be943 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 06/11] 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 95f450c6a2..6308ca86ad 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 69798276bc..34db25ef3d 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 bcd5f472b9..a2a1aa07a5 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 76d09edbed..7f13606edd 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 19c39bb989..b3a8786ce5 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 ed50a17fe1..8bdad04be2 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 4e2596714a..5e19ca8d8f 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 89be75d2a0..5323cdd501 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 d6015ce160..d18c622de1 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 783d18753d..5c587e8278 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 51931b012b..d2fa24562b 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 7a3fbf1ee8..e99ede9731 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 637ab9ba53..7f367e62a3 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 6167039ad1..7a8d799d8d 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 07/11] 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 7f13606edd..b6a6cdd832 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 b3a8786ce5..fbe52685c4 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 8bdad04be2..fe57e00238 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 9bff69e78f..24fdd90167 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 5e19ca8d8f..88ff253c11 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 d18c622de1..6b615dda5a 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 91a99e0dd2..ded0c69ff5 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 149b770b64..8473c60e46 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 d2c8cfb9fc..562419903b 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 a6191be943..f5afd754c5 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 7a8d799d8d..d9c62c2405 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 08/11] 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 562419903b..489113cbe0 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 f5afd754c5..6bb3afee02 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 09/11] 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 d2fa24562b..20218328bd 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 e99ede9731..c75632479d 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 10/11] 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 5c587e8278..efe636e5a3 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 11/11] =?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 eb8dd10312..98e9c91edf 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 `