From 0f40df8223beec58aed37460a88a3ca3a40b8b77 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 16:05:01 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(studio):=20stage=207=20step=203b=20?= =?UTF-8?q?=E2=80=94=20SDK=20shadow=20dispatch=20parity=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/studio/src/App.tsx | 1 + .../editor/manualEditingAvailability.ts | 9 + .../studio/src/hooks/useDomEditCommits.ts | 9 +- .../studio/src/hooks/useDomEditSession.ts | 8 + packages/studio/src/utils/sdkShadow.test.ts | 123 ++++++++++++ packages/studio/src/utils/sdkShadow.ts | 189 ++++++++++++++++++ 6 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/utils/sdkShadow.test.ts create mode 100644 packages/studio/src/utils/sdkShadow.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b1f9964b6..cad3ce99d 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -300,6 +300,7 @@ 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 7c702ac5f..e2d74d8ad 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -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"; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 2137a82fd..53cff7710 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -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; + /** 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, ], ); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index c6aa36a19..ae2797245 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -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 ── diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts new file mode 100644 index 000000000..925ab4d97 --- /dev/null +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -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 */ ` + +
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("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"); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts new file mode 100644 index 000000000..8caa2a493 --- /dev/null +++ b/packages/studio/src/utils/sdkShadow.ts @@ -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 = {}; + 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; +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) => ({ + 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), + }); +} From 192818111804d31e8039c94a9f3915a337223ac9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 21:55:45 -0700 Subject: [PATCH 2/4] fix(studio/sdkShadow): catch dispatch errors, return dispatch_error mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/studio/src/utils/sdkShadow.test.ts | 23 +++++++++++++++++++++ packages/studio/src/utils/sdkShadow.ts | 14 ++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index 925ab4d97..637ab9ba5 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -120,4 +120,27 @@ describe("sdkShadowDispatch (integration)", () => { 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 index 8caa2a493..19df636bf 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -57,11 +57,12 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO // ─── Shadow result types ────────────────────────────────────────────────────── export interface SdkShadowMismatch { - kind: "element_not_found" | "value_mismatch"; + kind: "element_not_found" | "value_mismatch" | "dispatch_error"; hfId: string; property?: string; expected?: string | null; actual?: string | null | undefined; + error?: string; } export interface SdkShadowResult { @@ -149,8 +150,15 @@ export function sdkShadowDispatch( if (!session.getElement(hfId)) { return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; } - for (const op of patchOpsToSdkEditOps(hfId, ops)) { - session.dispatch(op); + try { + for (const op of patchOpsToSdkEditOps(hfId, ops)) { + 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 From 5d101955aaaf60748811b38d81d1c56297645c92 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 23:12:26 -0700 Subject: [PATCH 3/4] fix(studio): dispose SDK session if cleanup fires during openComposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer found a race: if the effect cleanup runs while openComposition is awaited, comp is null so cleanup is a no-op, but the composition is then set and never disposed. Add an explicit check after the await so any composition opened after cancellation is disposed immediately. Also wire the missing useSdkSession call in App.tsx (sdkSession was referenced but never declared — pre-existing typecheck failure), move the stableRenderQueue memo into useRenderQueue so App.tsx stays under the 600-line architecture gate. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 16 +++------------ .../src/components/renders/useRenderQueue.ts | 20 +++++++++++-------- packages/studio/src/hooks/useSdkSession.ts | 7 ++++++- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index cad3ce99d..783d18753 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -266,6 +266,7 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); + const sdkSession = useSdkSession(projectId, activeCompPath); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -427,17 +428,6 @@ export function StudioApp() { applyDomSelection: domEditSession.applyDomSelection, initialState: initialUrlStateRef.current, }); - const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue; - const stableRenderQueue = useMemo( - () => ({ - jobs, - isRendering, - deleteRender, - clearCompleted, - startRender: startRender as (options: unknown) => Promise, - }), - [jobs, isRendering, deleteRender, clearCompleted, startRender], - ); const studioCtxValue = buildStudioContextValue({ projectId: projectId!, activeCompPath, @@ -453,7 +443,7 @@ export function StudioApp() { editHistory, handleUndo: appHotkeys.handleUndo, handleRedo: appHotkeys.handleRedo, - renderQueue: stableRenderQueue, + renderQueue, compositionDimensions, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, @@ -493,7 +483,7 @@ export function StudioApp() { refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime} inspectorButtonActive={inspectorButtonActive} inspectorPanelActive={inspectorPanelActive} - onExport={() => void renderQueue.startRender()} + onExport={() => void renderQueue.startRender(undefined)} /> {previewPersistence.domEditSaveQueuePaused && ( j.status === "rendering"), - }; + const isRendering = jobs.some((j) => j.status === "rendering"); + return useMemo( + () => ({ + jobs, + isRendering, + deleteRender, + clearCompleted, + startRender: startRender as (options: unknown) => Promise, + }), + [jobs, isRendering, deleteRender, clearCompleted, startRender], + ); } diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 8a7776b2e..7a3fbf1ee 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -76,7 +76,12 @@ export function useSdkSession( comp.on("persist:error", (e) => { console.warn("[sdk] persist:error", e.error); }); - if (!cancelled) setSession(comp); + // Cleanup may have fired while openComposition was awaited; dispose immediately. + if (cancelled) { + comp.dispose(); + return; + } + setSession(comp); }) .catch(() => { if (!cancelled) setSession(null); From a93118c246afef1cfb3e3259874b51399698094c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 23:21:18 -0700 Subject: [PATCH 4/4] fix(studio): batch shadow dispatch, rename runShadowDispatch, add PatchOperation import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Miguel Ángel --- packages/studio/src/hooks/useDomEditCommits.ts | 1 + packages/studio/src/hooks/useDomEditSession.ts | 4 ++-- packages/studio/src/utils/sdkShadow.ts | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 53cff7710..51931b012 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -9,6 +9,7 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { PersistDomEditOperations } from "./domEditCommitTypes"; +import type { PatchOperation } from "../utils/sourcePatcher"; import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index ae2797245..848fc9f5f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { reportShadowDispatch } from "../utils/sdkShadow"; +import { runShadowDispatch } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -233,7 +233,7 @@ export function useDomEditSession({ refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted: sdkSession - ? (sel, ops) => reportShadowDispatch(sdkSession, sel, ops) + ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) : undefined, }); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 19df636bf..6167039ad 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -151,9 +151,10 @@ export function sdkShadowDispatch( return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; } try { - for (const op of patchOpsToSdkEditOps(hfId, ops)) { - session.dispatch(op); - } + const sdkOps = patchOpsToSdkEditOps(hfId, ops); + session.batch(() => { + for (const op of sdkOps) session.dispatch(op); + }); } catch (err) { return { dispatched: false, @@ -171,9 +172,10 @@ export function sdkShadowDispatch( /** * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. - * No-op when STUDIO_SDK_SHADOW_ENABLED is false. + * 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 reportShadowDispatch( +export function runShadowDispatch( session: Composition, selection: DomEditSelection, ops: PatchOperation[],