feat(studio): stage 7 step 3c — sdk cutover for inline-style ops (#1522)

Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:10:07 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent e662fcdea2
commit ab7145ad9e
6 changed files with 511 additions and 21 deletions
+91
View File
@@ -0,0 +1,91 @@
import type { MutableRefObject } from "react";
import type { Composition } 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<PatchOperation["type"]>([
"inline-style",
"text-content",
"attribute",
"html-attribute",
]);
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))
);
}
interface CutoverDeps {
editHistory: {
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
writeProjectFile: (path: string, content: string) => Promise<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
}
interface CutoverOptions {
label?: string;
coalesceKey?: string;
}
export async function sdkCutoverPersist(
selection: DomEditSelection,
ops: PatchOperation[],
originalContent: string,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
return false;
if (!sdkSession) return false;
const hfId = selection.hfId;
if (!hfId) return false;
if (!sdkSession.getElement(hfId)) return false;
try {
sdkSession.batch(() => {
for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
sdkSession.dispatch(editOp);
}
});
const after = sdkSession.serialize();
deps.domEditSaveTimestampRef.current = Date.now();
await deps.writeProjectFile(targetPath, after);
await deps.editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
files: { [targetPath]: { before: originalContent, after } },
});
deps.reloadPreview();
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", {
hfId: selection.hfId ?? null,
error: String(err),
});
return false;
}
}