Skip to content

Commit 69aa595

Browse files
vanceingallsclaudeMiguel07Alm
authored
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>
1 parent 5fe87cc commit 69aa595

7 files changed

Lines changed: 377 additions & 9 deletions

File tree

packages/sdk-playground/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createFileAdapter } from "./fileAdapter.js";
33
import type { Composition, GsapTweenSpec, PreviewAdapter, FindQuery } from "@hyperframes/sdk";
44
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
55
import type { GsapAnimation } from "@hyperframes/core";
6+
// fallow-ignore-next-line unresolved-imports
67
import gsapRaw from "gsap/dist/gsap.min.js?raw";
78

89
// ── Demo composition ──────────────────────────────────────────────────────────

packages/studio/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export function StudioApp() {
302302
openSourceForSelection: fileManager.openSourceForSelection,
303303
selectSidebarTab: selectSidebarTabStable,
304304
getSidebarTab: getSidebarTabStable,
305+
sdkSession,
305306
});
306307
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
307308
clearDomSelectionRef.current = domEditSession.clearDomSelection;

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
8888

8989
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
9090

91+
// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
92+
// session alongside the server patch path and logs mismatches via telemetry.
93+
// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
94+
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
95+
env,
96+
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
97+
false,
98+
);
99+
91100
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
99
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
1010
import type { EditHistoryKind } from "../utils/editHistory";
1111
import type { PersistDomEditOperations } from "./domEditCommitTypes";
12+
import type { PatchOperation } from "../utils/sourcePatcher";
1213
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
1314
import { useDomEditTextCommits } from "./useDomEditTextCommits";
1415
import { useDomGeometryCommits } from "./useDomGeometryCommits";
1516
import { useElementLifecycleOps } from "./useElementLifecycleOps";
1617

17-
// Re-export so existing consumers keep their import path
18-
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
19-
2018
// ── Helpers ──
2119

2220
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
@@ -40,17 +38,13 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] }
4038
return `Couldn't save edit: ${body.error}${suffix}`;
4139
}
4240

43-
// ── Types ──
44-
4541
interface RecordEditInput {
4642
label: string;
4743
kind: EditHistoryKind;
4844
coalesceKey?: string;
4945
files: Record<string, { before: string; after: string }>;
5046
}
5147

52-
export type { PersistDomEditOperations } from "./domEditCommitTypes";
53-
5448
export interface UseDomEditCommitsParams {
5549
activeCompPath: string | null;
5650
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -77,10 +71,10 @@ export interface UseDomEditCommitsParams {
7771
target: HTMLElement,
7872
options?: { preferClipAncestor?: boolean },
7973
) => Promise<DomEditSelection | null>;
74+
/** Stage 7 Step 3b: called after a successful server-side element patch. */
75+
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
8076
}
8177

82-
// ── Hook ──
83-
8478
export function useDomEditCommits({
8579
activeCompPath,
8680
previewIframeRef,
@@ -99,6 +93,7 @@ export function useDomEditCommits({
9993
clearDomSelection,
10094
refreshDomEditSelectionFromPreview,
10195
buildDomSelectionFromTarget,
96+
onDomEditPersisted,
10297
}: UseDomEditCommitsParams) {
10398
const resolveImportedFontAsset = useCallback(
10499
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -220,6 +215,7 @@ export function useDomEditCommits({
220215
coalesceKey: options?.coalesceKey,
221216
files: { [targetPath]: { before: originalContent, after: finalContent } },
222217
});
218+
onDomEditPersisted?.(selection, operations);
223219

224220
if (!options?.skipRefresh) {
225221
reloadPreview();
@@ -233,6 +229,7 @@ export function useDomEditCommits({
233229
domEditSaveTimestampRef,
234230
reloadPreview,
235231
showToast,
232+
onDomEditPersisted,
236233
],
237234
);
238235

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Composition } from "@hyperframes/sdk";
12
import type { TimelineElement } from "../player";
23
import type { ImportedFontAsset } from "../components/editor/fontAssets";
34
import type { EditHistoryKind } from "../utils/editHistory";
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
89
import { useDomSelection } from "./useDomSelection";
910
import { usePreviewInteraction } from "./usePreviewInteraction";
1011
import { useDomEditCommits } from "./useDomEditCommits";
12+
import { runShadowDispatch } from "../utils/sdkShadow";
1113
import { useGsapScriptCommits } from "./useGsapScriptCommits";
1214
import { useGsapCacheVersion } from "./useGsapTweenCache";
1315
import { useDomEditWiring } from "./useDomEditWiring";
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
5860
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
5961
selectSidebarTab?: (tab: SidebarTab) => void;
6062
getSidebarTab?: () => SidebarTab;
63+
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
64+
sdkSession?: Composition | null;
6165
}
6266

6367
// ── Hook ──
@@ -96,6 +100,7 @@ export function useDomEditSession({
96100
openSourceForSelection,
97101
selectSidebarTab,
98102
getSidebarTab,
103+
sdkSession,
99104
}: UseDomEditSessionParams) {
100105
void _setRefreshKey;
101106
void _readProjectFile;
@@ -227,6 +232,9 @@ export function useDomEditSession({
227232
clearDomSelection,
228233
refreshDomEditSelectionFromPreview,
229234
buildDomSelectionFromTarget,
235+
onDomEditPersisted: sdkSession
236+
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
237+
: undefined,
230238
});
231239

232240
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from "vitest";
2+
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
3+
import type { PatchOperation } from "./sourcePatcher";
4+
import { openComposition } from "@hyperframes/sdk";
5+
6+
const BASE_HTML = /* html */ `<!DOCTYPE html>
7+
<html><body>
8+
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
9+
</body></html>`;
10+
11+
describe("patchOpsToSdkEditOps", () => {
12+
it("maps inline-style ops to a single setStyle EditOp", () => {
13+
const ops: PatchOperation[] = [
14+
{ type: "inline-style", property: "color", value: "#00f" },
15+
{ type: "inline-style", property: "opacity", value: "0.5" },
16+
];
17+
const result = patchOpsToSdkEditOps("hf-box", ops);
18+
expect(result).toHaveLength(1);
19+
expect(result[0]).toEqual({
20+
type: "setStyle",
21+
target: "hf-box",
22+
styles: { color: "#00f", opacity: "0.5" },
23+
});
24+
});
25+
26+
it("maps text-content op to setText EditOp", () => {
27+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
28+
const result = patchOpsToSdkEditOps("hf-box", ops);
29+
expect(result).toHaveLength(1);
30+
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
31+
});
32+
33+
it("maps attribute op to setAttribute with data- prefix", () => {
34+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
35+
const result = patchOpsToSdkEditOps("hf-box", ops);
36+
expect(result).toHaveLength(1);
37+
expect(result[0]).toEqual({
38+
type: "setAttribute",
39+
target: "hf-box",
40+
name: "data-name",
41+
value: "hero",
42+
});
43+
});
44+
45+
it("maps html-attribute op to setAttribute without prefix", () => {
46+
const ops: PatchOperation[] = [
47+
{ type: "html-attribute", property: "contenteditable", value: "true" },
48+
];
49+
const result = patchOpsToSdkEditOps("hf-box", ops);
50+
expect(result).toHaveLength(1);
51+
expect(result[0]).toEqual({
52+
type: "setAttribute",
53+
target: "hf-box",
54+
name: "contenteditable",
55+
value: "true",
56+
});
57+
});
58+
59+
it("handles null value for attribute removal", () => {
60+
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
61+
const result = patchOpsToSdkEditOps("hf-box", ops);
62+
expect(result[0]).toEqual({
63+
type: "setAttribute",
64+
target: "hf-box",
65+
name: "hidden",
66+
value: null,
67+
});
68+
});
69+
70+
it("returns empty array for unknown op types", () => {
71+
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
72+
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
73+
});
74+
});
75+
76+
describe("sdkShadowDispatch (integration)", () => {
77+
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
78+
const { sdkShadowDispatch } = await import("./sdkShadow");
79+
const session = await openComposition(BASE_HTML);
80+
81+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
82+
const result = sdkShadowDispatch(session, "hf-box", ops);
83+
84+
expect(result.dispatched).toBe(true);
85+
expect(result.mismatches).toHaveLength(0);
86+
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
87+
});
88+
89+
it("returns dispatched:false when hfId not found in session", async () => {
90+
const { sdkShadowDispatch } = await import("./sdkShadow");
91+
const session = await openComposition(BASE_HTML);
92+
93+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
94+
const result = sdkShadowDispatch(session, "hf-missing", ops);
95+
96+
expect(result.dispatched).toBe(false);
97+
expect(result.mismatches).toHaveLength(1);
98+
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
99+
kind: "element_not_found",
100+
hfId: "hf-missing",
101+
});
102+
});
103+
104+
it("applies text op and reads back via session.getElement", async () => {
105+
const { sdkShadowDispatch } = await import("./sdkShadow");
106+
const session = await openComposition(BASE_HTML);
107+
108+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
109+
sdkShadowDispatch(session, "hf-box", ops);
110+
111+
expect(session.getElement("hf-box")?.text).toBe("Updated");
112+
});
113+
114+
it("applies attribute op and reads back via session.getElement", async () => {
115+
const { sdkShadowDispatch } = await import("./sdkShadow");
116+
const session = await openComposition(BASE_HTML);
117+
118+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
119+
sdkShadowDispatch(session, "hf-box", ops);
120+
121+
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
122+
});
123+
124+
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
125+
const { sdkShadowDispatch } = await import("./sdkShadow");
126+
const session = await openComposition(BASE_HTML);
127+
// Poison dispatch so it throws on any call
128+
session.dispatch = () => {
129+
throw new Error("sdk internal error");
130+
};
131+
132+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
133+
let result: ReturnType<typeof sdkShadowDispatch> | undefined;
134+
expect(() => {
135+
result = sdkShadowDispatch(session, "hf-box", ops);
136+
}).not.toThrow();
137+
138+
expect(result!.dispatched).toBe(false);
139+
expect(result!.mismatches).toHaveLength(1);
140+
expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
141+
kind: "dispatch_error",
142+
hfId: "hf-box",
143+
error: expect.stringContaining("sdk internal error"),
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)