diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 207a366a14..4e64b07299 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -304,7 +304,6 @@ 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 8d8a49ba19..7c702ac5f5 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -88,22 +88,4 @@ 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, -); - -// 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.ts b/packages/studio/src/hooks/useDomEditSession.ts index 848fc9f5f5..c6aa36a19f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,3 @@ -import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -9,7 +8,6 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { runShadowDispatch } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -60,8 +58,6 @@ 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 ── @@ -100,7 +96,6 @@ export function useDomEditSession({ openSourceForSelection, selectSidebarTab, getSidebarTab, - sdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -232,9 +227,6 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted: sdkSession - ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) - : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 489113cbe0..0473fd55af 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -5,9 +5,6 @@ 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(), })); @@ -37,42 +34,38 @@ const htmlAttrOp = (property: string, value: string): PatchOperation => ({ }); 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); + expect(shouldUseSdkCutover(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); + expect(shouldUseSdkCutover(true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, undefined, [styleOp("color", "red")])).toBe(false); }); it("returns false when ops empty", () => { - expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); + expect(shouldUseSdkCutover(true, "hf-abc", [])).toBe(false); }); it("returns true for inline-style ops", () => { - expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); + expect(shouldUseSdkCutover(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); + expect(shouldUseSdkCutover(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); + expect(shouldUseSdkCutover(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); + expect(shouldUseSdkCutover(true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true); }); it("returns true when ops mix all supported types", () => { expect( - shouldUseSdkCutover(true, true, "hf-abc", [ + shouldUseSdkCutover(true, "hf-abc", [ styleOp("color", "red"), textOp("hello"), attrOp("x", "1"), diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 6bb3afee02..2ef1855cd4 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -1,10 +1,9 @@ import type { MutableRefObject } from "react"; import type { Composition } from "@hyperframes/sdk"; +import type { EditOp } 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([ @@ -14,19 +13,48 @@ const CUTOVER_OP_TYPES = new Set([ "html-attribute", ]); +/** + * 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. + */ +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: op.property.startsWith("data-") ? op.property : `data-${op.property}`, + value: op.value, + }); + } else if (op.type === "html-attribute") { + result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); + } + } + + if (hasStyles) { + result.unshift({ type: "setStyle", target: hfId, styles }); + } + + return result; +} + 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)) - ); + return hasSession && !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)); } interface CutoverDeps { @@ -57,8 +85,7 @@ export async function sdkCutoverPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) - return false; + if (!shouldUseSdkCutover(!!sdkSession, selection.hfId, ops)) return false; if (!sdkSession) return false; const hfId = selection.hfId; if (!hfId) return false; diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts deleted file mode 100644 index 7f367e62a3..0000000000 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -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("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" }, - ]; - 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"); - }); - - 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 deleted file mode 100644 index d9c62c2405..0000000000 --- a/packages/studio/src/utils/sdkShadow.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * 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: op.property.startsWith("data-") ? op.property : `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" | "dispatch_error"; - hfId: string; - property?: string; - expected?: string | null; - actual?: string | null | undefined; - error?: string; -} - -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) => { - 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, - 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 }] }; - } - try { - const sdkOps = patchOpsToSdkEditOps(hfId, ops); - session.batch(() => { - for (const op of sdkOps) 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 - .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. - * 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 runShadowDispatch( - 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), - }); -}