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>
This commit is contained in:
Vance Ingalls
2026-06-15 00:23:31 -07:00
co-authored by Claude Opus 4.8
parent d86e97c264
commit 450267ea53
6 changed files with 335 additions and 4 deletions
+1
View File
@@ -301,6 +301,7 @@ export function StudioApp() {
openSourceForSelection: fileManager.openSourceForSelection,
selectSidebarTab: selectSidebarTabStable,
getSidebarTab: getSidebarTabStable,
sdkSession,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
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;
// 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";
@@ -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<DomEditSelection | null>;
/** 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,
],
);
@@ -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 ──
+123
View File
@@ -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 */ `<!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");
});
});
+189
View File
@@ -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<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";
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<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.
*/
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),
});
}