Skip to content

Commit 89f83f7

Browse files
committed
feat(studio): stage 7 step 3c — sdk cutover for inline-style ops
1 parent 266403f commit 89f83f7

6 files changed

Lines changed: 302 additions & 33 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,13 @@ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
104104
false,
105105
);
106106

107+
// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
108+
// instead of the server patch-element API. Default false; enable via
109+
// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
110+
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
111+
env,
112+
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
113+
false,
114+
);
115+
107116
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ interface RecordEditInput {
8181
files: Record<string, { before: string; after: string }>;
8282
}
8383

84+
type TrySdkPersist = (
85+
s: DomEditSelection,
86+
ops: PatchOperation[],
87+
html: string,
88+
path: string,
89+
) => Promise<boolean>;
90+
8491
export type PersistDomEditOperations = (
8592
selection: DomEditSelection,
8693
operations: PatchOperation[],
@@ -119,8 +126,8 @@ export interface UseDomEditCommitsParams {
119126
target: HTMLElement,
120127
options?: { preferClipAncestor?: boolean },
121128
) => Promise<DomEditSelection | null>;
122-
/** Stage 7 Step 3b: called after a successful server-side element patch. */
123129
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
130+
onTrySdkPersist?: TrySdkPersist;
124131
}
125132

126133
export function useDomEditCommits({
@@ -142,6 +149,7 @@ export function useDomEditCommits({
142149
refreshDomEditSelectionFromPreview,
143150
buildDomSelectionFromTarget,
144151
onDomEditPersisted,
152+
onTrySdkPersist,
145153
}: UseDomEditCommitsParams) {
146154
const resolveImportedFontAsset = useCallback(
147155
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -190,11 +198,10 @@ export function useDomEditCommits({
190198

191199
if (options?.shouldSave && !options.shouldSave()) return;
192200

193-
const patchTarget = buildDomEditPatchTarget(selection);
201+
if (onTrySdkPersist)
202+
if (await onTrySdkPersist(selection, operations, originalContent, targetPath)) return;
194203

195-
// Mark the save timestamp before the file write so the SSE file-change
196-
// handler suppresses the reload even if the event arrives before the
197-
// response (the server writes the file and emits SSE during the fetch).
204+
const patchTarget = buildDomEditPatchTarget(selection);
198205
domEditSaveTimestampRef.current = Date.now();
199206

200207
const patchResponse = await fetch(
@@ -265,11 +272,10 @@ export function useDomEditCommits({
265272
domEditSaveTimestampRef,
266273
reloadPreview,
267274
onDomEditPersisted,
275+
onTrySdkPersist,
268276
],
269277
);
270278

271-
// ── Text & style commits (delegated to useDomEditTextCommits) ──
272-
273279
const {
274280
handleDomStyleCommit,
275281
handleDomAttributeCommit,
@@ -290,8 +296,6 @@ export function useDomEditCommits({
290296
resolveImportedFontAsset,
291297
});
292298

293-
// ── Position patch helper ──
294-
295299
// fallow-ignore-next-line complexity
296300
const commitPositionPatchToHtml = useCallback(
297301
(
@@ -322,8 +326,6 @@ export function useDomEditCommits({
322326
[persistDomEditOperations, queueDomEditSave, showToast],
323327
);
324328

325-
// ── Position commits ──
326-
327329
const handleDomPathOffsetCommit = useCallback(
328330
(selection: DomEditSelection, next: { x: number; y: number }) => {
329331
applyStudioPathOffset(selection.element, next);
@@ -400,8 +402,6 @@ export function useDomEditCommits({
400402
[commitPositionPatchToHtml],
401403
);
402404

403-
// ── Motion commits (HTML-attribute–backed) ──
404-
405405
// fallow-ignore-next-line complexity
406406
const handleDomMotionCommit = useCallback(
407407
(
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { shouldUseSdkCutover } from "../utils/sdkCutover";
3+
import type { PatchOperation } from "../utils/sourcePatcher";
4+
5+
const styleOp = (property: string, value: string): PatchOperation => ({
6+
type: "inline-style",
7+
property,
8+
value,
9+
});
10+
11+
const attrOp = (property: string, value: string): PatchOperation => ({
12+
type: "attribute",
13+
property,
14+
value,
15+
});
16+
17+
describe("shouldUseSdkCutover", () => {
18+
it("returns false when flag is disabled", () => {
19+
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
20+
});
21+
22+
it("returns false when no SDK session", () => {
23+
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
24+
});
25+
26+
it("returns false when selection has no hfId", () => {
27+
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
28+
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
29+
});
30+
31+
it("returns false when ops include non-inline-style types", () => {
32+
expect(
33+
shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]),
34+
).toBe(false);
35+
});
36+
37+
it("returns false when ops array is empty", () => {
38+
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
39+
});
40+
41+
it("returns true when flag on, session present, hfId set, all ops inline-style", () => {
42+
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
43+
expect(
44+
shouldUseSdkCutover(true, true, "hf-abc", [
45+
styleOp("color", "red"),
46+
styleOp("opacity", "0.5"),
47+
]),
48+
).toBe(true);
49+
});
50+
});

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
1111
import type { ImportedFontAsset } from "../components/editor/fontAssets";
1212
import type { EditHistoryKind } from "../utils/editHistory";
1313
import type { RightPanelTab } from "../utils/studioHelpers";
14-
import type { PatchTarget } from "../utils/sourcePatcher";
14+
import type { PatchOperation, PatchTarget } from "../utils/sourcePatcher";
1515
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
1616
import { useAskAgentModal } from "./useAskAgentModal";
1717
import { useDomSelection } from "./useDomSelection";
1818
import { usePreviewInteraction } from "./usePreviewInteraction";
1919
import { useDomEditCommits } from "./useDomEditCommits";
2020
import { reportShadowDispatch } from "../utils/sdkShadow";
21+
import { sdkCutoverPersist } from "../utils/sdkCutover";
2122
import { useGsapScriptCommits } from "./useGsapScriptCommits";
2223
import {
2324
useGsapAnimationsForElement,
@@ -34,8 +35,6 @@ import {
3435
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
3536
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
3637

37-
// ── Types ──
38-
3938
interface RecordEditInput {
4039
label: string;
4140
kind: EditHistoryKind;
@@ -79,12 +78,9 @@ export interface UseDomEditSessionParams {
7978
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
8079
selectSidebarTab?: (tab: SidebarTab) => void;
8180
getSidebarTab?: () => SidebarTab;
82-
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
8381
sdkSession?: Composition | null;
8482
}
8583

86-
// ── Hook ──
87-
8884
// fallow-ignore-next-line complexity
8985
export function useDomEditSession({
9086
projectId,
@@ -138,8 +134,6 @@ export function useDomEditSession({
138134
[openSourceForSelection, selectSidebarTab],
139135
);
140136

141-
// ── Selection (delegated to useDomSelection) ──
142-
143137
const {
144138
domEditSelection,
145139
domEditGroupSelections,
@@ -170,8 +164,6 @@ export function useDomEditSession({
170164
rightPanelTab,
171165
});
172166

173-
// ── Agent modal (delegated to useAskAgentModal) ──
174-
175167
const {
176168
agentModalOpen,
177169
agentModalAnchorPoint,
@@ -192,8 +184,6 @@ export function useDomEditSession({
192184
domEditSelection,
193185
});
194186

195-
// ── Preview interaction (delegated to usePreviewInteraction) ──
196-
197187
const {
198188
handlePreviewCanvasMouseDown,
199189
handlePreviewCanvasPointerMove,
@@ -212,8 +202,7 @@ export function useDomEditSession({
212202
onClickToSource,
213203
});
214204

215-
// Sync DOM selection → timeline selectedElementId so that clip selection
216-
// highlights and diamond playhead fills work on cold-load URL restore.
205+
// Sync DOM selection → timeline selectedElementId for cold-load URL restore.
217206
useEffect(() => {
218207
if (!domEditSelection?.id) return;
219208
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
@@ -224,13 +213,9 @@ export function useDomEditSession({
224213
if (key && key !== selectedElementId) setSelectedElementId(key);
225214
}, [domEditSelection?.id]);
226215

227-
// ── GSAP script editing ──
228-
229216
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
230217

231-
// Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
232-
// reload via refreshKey but don't go through commitMutation, so the cache
233-
// would otherwise retain stale keyframe entries).
218+
// Bump GSAP cache on refreshKey — code-tab edits bypass commitMutation.
234219
const prevRefreshKeyRef = useRef(refreshKey);
235220
// eslint-disable-next-line no-restricted-syntax
236221
useEffect(() => {
@@ -292,7 +277,16 @@ export function useDomEditSession({
292277
onFileContentChanged: updateEditingFileContent,
293278
});
294279

295-
// ── Commit handlers (delegated to useDomEditCommits) ──
280+
const onTrySdkPersist = useCallback(
281+
(sel: DomEditSelection, ops: PatchOperation[], html: string, path: string) =>
282+
sdkCutoverPersist(sel, ops, html, path, sdkSession, {
283+
editHistory,
284+
writeProjectFile,
285+
reloadPreview,
286+
domEditSaveTimestampRef,
287+
}),
288+
[sdkSession, editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef],
289+
);
296290

297291
const {
298292
resolveImportedFontAsset,
@@ -333,6 +327,7 @@ export function useDomEditSession({
333327
onDomEditPersisted: sdkSession
334328
? (sel, ops) => reportShadowDispatch(sdkSession, sel, ops)
335329
: undefined,
330+
onTrySdkPersist,
336331
});
337332

338333
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.

0 commit comments

Comments
 (0)