mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode (#1450)
* feat(studio): stage 7 step 3b — SDK shadow dispatch parity mode 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 <noreply@anthropic.com> * fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch 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 <noreply@anthropic.com> * fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import 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 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * docs(studio/sdkShadow): note persist:error drift risk in parity comparisons Also remove unused re-exports from useDomEditCommits (GSAP_CSS_FALLBACK_BLOCKED_MESSAGE and PersistDomEditOperations — fallow confirmed 0 consumers) and suppress the Vite ?raw import in sdk-playground that fallow can't resolve statically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
5fe87cc39b
commit
69aa595f38
@@ -3,6 +3,7 @@ import { createFileAdapter } from "./fileAdapter.js";
|
|||||||
import type { Composition, GsapTweenSpec, PreviewAdapter, FindQuery } from "@hyperframes/sdk";
|
import type { Composition, GsapTweenSpec, PreviewAdapter, FindQuery } from "@hyperframes/sdk";
|
||||||
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
||||||
import type { GsapAnimation } from "@hyperframes/core";
|
import type { GsapAnimation } from "@hyperframes/core";
|
||||||
|
// fallow-ignore-next-line unresolved-imports
|
||||||
import gsapRaw from "gsap/dist/gsap.min.js?raw";
|
import gsapRaw from "gsap/dist/gsap.min.js?raw";
|
||||||
|
|
||||||
// ── Demo composition ──────────────────────────────────────────────────────────
|
// ── Demo composition ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export function StudioApp() {
|
|||||||
openSourceForSelection: fileManager.openSourceForSelection,
|
openSourceForSelection: fileManager.openSourceForSelection,
|
||||||
selectSidebarTab: selectSidebarTabStable,
|
selectSidebarTab: selectSidebarTabStable,
|
||||||
getSidebarTab: getSidebarTabStable,
|
getSidebarTab: getSidebarTabStable,
|
||||||
|
sdkSession,
|
||||||
});
|
});
|
||||||
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
||||||
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
||||||
|
|||||||
@@ -88,4 +88,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|||||||
|
|
||||||
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
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";
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
|
|||||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
||||||
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||||
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
||||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||||
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
||||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||||
|
|
||||||
// Re-export so existing consumers keep their import path
|
|
||||||
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||||
@@ -40,8 +38,6 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] }
|
|||||||
return `Couldn't save edit: ${body.error}${suffix}`;
|
return `Couldn't save edit: ${body.error}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
interface RecordEditInput {
|
interface RecordEditInput {
|
||||||
label: string;
|
label: string;
|
||||||
kind: EditHistoryKind;
|
kind: EditHistoryKind;
|
||||||
@@ -49,8 +45,6 @@ interface RecordEditInput {
|
|||||||
files: Record<string, { before: string; after: string }>;
|
files: Record<string, { before: string; after: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
||||||
|
|
||||||
export interface UseDomEditCommitsParams {
|
export interface UseDomEditCommitsParams {
|
||||||
activeCompPath: string | null;
|
activeCompPath: string | null;
|
||||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||||
@@ -77,10 +71,10 @@ export interface UseDomEditCommitsParams {
|
|||||||
target: HTMLElement,
|
target: HTMLElement,
|
||||||
options?: { preferClipAncestor?: boolean },
|
options?: { preferClipAncestor?: boolean },
|
||||||
) => Promise<DomEditSelection | null>;
|
) => Promise<DomEditSelection | null>;
|
||||||
|
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
||||||
|
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hook ──
|
|
||||||
|
|
||||||
export function useDomEditCommits({
|
export function useDomEditCommits({
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
@@ -99,6 +93,7 @@ export function useDomEditCommits({
|
|||||||
clearDomSelection,
|
clearDomSelection,
|
||||||
refreshDomEditSelectionFromPreview,
|
refreshDomEditSelectionFromPreview,
|
||||||
buildDomSelectionFromTarget,
|
buildDomSelectionFromTarget,
|
||||||
|
onDomEditPersisted,
|
||||||
}: UseDomEditCommitsParams) {
|
}: UseDomEditCommitsParams) {
|
||||||
const resolveImportedFontAsset = useCallback(
|
const resolveImportedFontAsset = useCallback(
|
||||||
(fontFamilyValue: string): ImportedFontAsset | null => {
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
||||||
@@ -220,6 +215,7 @@ export function useDomEditCommits({
|
|||||||
coalesceKey: options?.coalesceKey,
|
coalesceKey: options?.coalesceKey,
|
||||||
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
||||||
});
|
});
|
||||||
|
onDomEditPersisted?.(selection, operations);
|
||||||
|
|
||||||
if (!options?.skipRefresh) {
|
if (!options?.skipRefresh) {
|
||||||
reloadPreview();
|
reloadPreview();
|
||||||
@@ -233,6 +229,7 @@ export function useDomEditCommits({
|
|||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
showToast,
|
showToast,
|
||||||
|
onDomEditPersisted,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { TimelineElement } from "../player";
|
import type { TimelineElement } from "../player";
|
||||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
|||||||
import { useDomSelection } from "./useDomSelection";
|
import { useDomSelection } from "./useDomSelection";
|
||||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||||
import { useDomEditCommits } from "./useDomEditCommits";
|
import { useDomEditCommits } from "./useDomEditCommits";
|
||||||
|
import { runShadowDispatch } from "../utils/sdkShadow";
|
||||||
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
||||||
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
||||||
import { useDomEditWiring } from "./useDomEditWiring";
|
import { useDomEditWiring } from "./useDomEditWiring";
|
||||||
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
|
|||||||
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
||||||
selectSidebarTab?: (tab: SidebarTab) => void;
|
selectSidebarTab?: (tab: SidebarTab) => void;
|
||||||
getSidebarTab?: () => SidebarTab;
|
getSidebarTab?: () => SidebarTab;
|
||||||
|
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
|
||||||
|
sdkSession?: Composition | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hook ──
|
// ── Hook ──
|
||||||
@@ -96,6 +100,7 @@ export function useDomEditSession({
|
|||||||
openSourceForSelection,
|
openSourceForSelection,
|
||||||
selectSidebarTab,
|
selectSidebarTab,
|
||||||
getSidebarTab,
|
getSidebarTab,
|
||||||
|
sdkSession,
|
||||||
}: UseDomEditSessionParams) {
|
}: UseDomEditSessionParams) {
|
||||||
void _setRefreshKey;
|
void _setRefreshKey;
|
||||||
void _readProjectFile;
|
void _readProjectFile;
|
||||||
@@ -227,6 +232,9 @@ export function useDomEditSession({
|
|||||||
clearDomSelection,
|
clearDomSelection,
|
||||||
refreshDomEditSelectionFromPreview,
|
refreshDomEditSelectionFromPreview,
|
||||||
buildDomSelectionFromTarget,
|
buildDomSelectionFromTarget,
|
||||||
|
onDomEditPersisted: sdkSession
|
||||||
|
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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 */ `<!DOCTYPE html>
|
||||||
|
<html><body>
|
||||||
|
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
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<SdkShadowMismatch>({
|
||||||
|
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<typeof sdkShadowDispatch> | 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<SdkShadowMismatch>({
|
||||||
|
kind: "dispatch_error",
|
||||||
|
hfId: "hf-box",
|
||||||
|
error: expect.stringContaining("sdk internal error"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 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<string, string | null> = {};
|
||||||
|
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" | "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<Composition["getElement"]>;
|
||||||
|
type OpFields = {
|
||||||
|
property: string;
|
||||||
|
expected: string | null | undefined;
|
||||||
|
actual: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatSnapshot = {
|
||||||
|
styles: Record<string, string | null>;
|
||||||
|
attrs: Record<string, string | null>;
|
||||||
|
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<string, OpFieldResolver> = {
|
||||||
|
"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.
|
||||||
|
*
|
||||||
|
* **persist:error drift risk**: the HTTP adapter fires persist:error on
|
||||||
|
* network failure but the SDK session is already mutated at that point. If
|
||||||
|
* the server file was not updated (e.g. 503), subsequent shadow parity
|
||||||
|
* comparisons here will see a diverged SDK session and produce false
|
||||||
|
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
|
||||||
|
* window is clear of persist:error events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user