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..b6a6cdd83 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; @@ -61,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 { @@ -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)) + .catch(() => {}); + 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..fbe52685c 100644 --- a/packages/sdk/src/adapters/http.test.ts +++ b/packages/sdk/src/adapters/http.test.ts @@ -59,11 +59,32 @@ 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"); + }); + + 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 ed50a17fe..fe57e0023 100644 --- a/packages/sdk/src/adapters/http.ts +++ b/packages/sdk/src/adapters/http.ts @@ -30,8 +30,14 @@ 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; - const data = (await res.json()) as { content?: string }; + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + 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 4e2596714..88ff253c1 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -177,16 +177,34 @@ 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, { + expect(() => + applyOp(noScript, { + type: "addGsapTween", + target: "hf-box", + tween: { method: "to", properties: { x: 1 } }, + }), + ).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-box", - tween: { method: "to", properties: { x: 1 } }, + target: "hf-stage/hf-box", + tween: { method: "to", properties: { x: 100 } }, }); - expect(result.forward).toHaveLength(0); + 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"); }); }); @@ -477,3 +495,52 @@ 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(); + }); + + 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.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 5e6efd0c2..a1d5a40f4 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}`)) ); } @@ -579,6 +584,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 = {}; @@ -591,8 +598,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 } : {}), @@ -617,6 +625,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 = {}; @@ -643,6 +653,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; @@ -699,6 +711,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/App.tsx b/packages/studio/src/App.tsx index 783d18753..efe636e5a 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; @@ -266,7 +269,6 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); - const sdkSession = useSdkSession(projectId, activeCompPath); const domEditSession = useDomEditSession({ projectId, activeCompPath, 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/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 51931b012..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 => { @@ -134,7 +142,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 +153,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 +174,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)}`, { @@ -235,6 +246,7 @@ export function useDomEditCommits({ reloadPreview, showToast, onDomEditPersisted, + onTrySdkPersist, ], ); diff --git a/packages/studio/src/hooks/useDomEditSession.test.ts b/packages/studio/src/hooks/useDomEditSession.test.ts new file mode 100644 index 000000000..040d83b3b --- /dev/null +++ b/packages/studio/src/hooks/useDomEditSession.test.ts @@ -0,0 +1,41 @@ +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 array is empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + 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"), attrOp("data-x", "1")]), + ).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 7a3fbf1ee..c75632479 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,18 @@ 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( projectId: string | null, activeCompPath: string | null, + domEditSaveTimestampRef?: MutableRefObject, ): Composition | null { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -38,9 +48,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 +65,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 +76,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 +85,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 +97,7 @@ export function useSdkSession( comp.dispose(); return; } + compRef.current = comp; setSession(comp); }) .catch(() => { @@ -89,7 +106,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/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts new file mode 100644 index 000000000..489113cbe --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -0,0 +1,335 @@ +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"; + +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 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); + }); + + 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 empty", () => { + expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + }); + + 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", () => { + 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(""), + batch: vi.fn((fn: () => void) => fn()), + }) 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 for inline-style ops", 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("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("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); + (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(); + }); + + 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 new file mode 100644 index 000000000..6bb3afee0 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.ts @@ -0,0 +1,91 @@ +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 { 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, + hfId: string | null | undefined, + ops: PatchOperation[], +): boolean { + return ( + flagEnabled && + hasSession && + !!hfId && + ops.length > 0 && + ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) + ); +} + +interface CutoverDeps { + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + writeProjectFile: (path: string, content: string) => Promise; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; +} + +interface CutoverOptions { + label?: string; + coalesceKey?: string; +} + +export async function sdkCutoverPersist( + selection: DomEditSelection, + ops: PatchOperation[], + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): 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 { + 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); + await deps.editHistory.recordEdit({ + label: options?.label ?? "Edit layer", + kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), + 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; + } +} 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..d9c62c240 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") { @@ -98,18 +98,21 @@ 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, 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,