Files
hyperframes/packages/studio/src/hooks/useDomEditCommits.ts
T
Vance IngallsandClaude Opus 4.8 8f15e9f09b feat(studio): extend SDK shadow to delete/timing/gsap-add + default on (#1473)
* feat(studio): default SDK shadow dispatch on for parity telemetry

Shadow mode keeps the server patch path authoritative (no user-visible
change) and emits sdk_shadow_dispatch parity signal. Default it on so we
collect addressing/serialize-drift telemetry from all traffic before any
cutover. Disable via VITE_STUDIO_SDK_SHADOW_ENABLED=false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): shadow parity for delete/timing/gsap ops + wire delete

Extends shadow visibility past the property-edit path. Adds a can()-first
shadow core (pure addressing/validity pre-check, works even for GSAP which
has no snapshot value) plus runShadowDelete/runShadowTiming/runShadowGsapTween.
Parity coverage: delete = getElement null (full); timing = snapshot
start/duration/trackIndex (full); gsap = can()+dispatch+returned-id only
(animationIds is a stub, tween values are script-level — full fidelity needs
serialize() round-trip diffing, out of scope).

Wires the delete runner end-to-end via an onElementDeleted callback
(useDomEditSession → useDomEditCommits → useElementLifecycleOps), fired after
the server delete succeeds. Server stays authoritative. Timing/GSAP wiring
follows (each needs threading sdkSession into useTimelineEditing /
useGsapScriptCommits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): wire timing + GSAP-add shadow dispatch

Timing: thread sdkSession into useTimelineEditing; fire runShadowTiming after
move/resize persist (server authoritative). Moved the useSdkSession call above
useTimelineEditing so both share the single session (no duplicate).

GSAP: thread sdkSession through useGsapScriptCommits → useGsapAnimationOps;
shadow addGsapAnimation via runShadowGsapTween after the server add. Only the
add path is shadowed — delete/update key on the server's animationId, which
doesn't resolve in the SDK's independent id-space (would emit false
cannot_dispatch). "set" has no SDK method, so it's skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): address #1473 review — no-persist shadow session + fallow gate

Blocker (Rames): the shadow runners dispatched on the live persisted SDK
session, so each shadow op fired the persist queue → an HTTP write of the SDK's
serialize() output, clobbering the studio's authoritative write (default-on
shipped this). Fix: open the shadow session WITHOUT persist — it reads from the
server but never writes back. Shadow dispatches mutate the in-memory model only
and are discarded on the next reload-on-change. Cutover (Step 3c+) must re-add
persist together with self-write suppression. No persist consumer exists in
this stack (cutover is not in main), so this is safe and keeps default-on.

Fallow CI gate (Miguel):
- drop unused `export` on RecordEditInput (dead-type)
- suppress pre-existing CRAP with reasons: commitMutation, addGsapAnimation;
  file-level complexity on useTimelineEditing (shadow .then() branches nudge
  several callbacks over threshold — telemetry-only)
- suppress 3 pre-existing clones surfaced by adjacent edits (save-error
  formatter, prop-drilling passthrough, file-change reload handler)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): scrub user content from shadow property-path telemetry

Addresses #1473 review concern (Rames): inline-style and text-content edits
put user content into the sdk_shadow_dispatch mismatch expected/actual fields.
Redact before emit — text-content values fully redacted (length only), others
length-capped at 64. The in-memory parity result keeps raw values, so the
parity logic and tests are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:08:29 -07:00

321 lines
11 KiB
TypeScript

import { useCallback, useRef } from "react";
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
import { FONT_EXT } from "../utils/mediaTypes";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
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";
import { useElementLifecycleOps } from "./useElementLifecycleOps";
// ── Helpers ──
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
return fields.map((field) => field.path).join(", ");
}
async function readErrorResponseBody(
response: Response,
): Promise<{ error?: string; fields?: string[] } | null> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
}
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
if (!body?.error) return "Couldn't save edit";
// Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers);
// surfaced here by this PR's adjacent edits, not introduced by it.
// fallow-ignore-next-line code-duplication
const fields = Array.isArray(body.fields)
? body.fields.filter((field): field is string => typeof field === "string")
: [];
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
return `Couldn't save edit: ${body.error}${suffix}`;
}
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
projectId: string | null;
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
clearDomSelection: () => void;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
buildDomSelectionFromTarget: (
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;
/** Stage 7 Step 3b: called after a successful server-side element delete. */
onElementDeleted?: (selection: DomEditSelection) => void;
}
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
queueDomEditSave,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectId,
projectIdRef,
reloadPreview,
domEditSelection,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted,
onElementDeleted,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
const family = primaryFontFamilyValue(fontFamilyValue);
if (!family) return null;
const imported = importedFontAssetsRef.current.find(
(font) => font.family.toLowerCase() === family.toLowerCase(),
);
if (imported) return imported;
const asset = fileTree.find(
(path) =>
FONT_EXT.test(path) &&
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
);
if (!asset) return null;
return {
family: fontFamilyFromAssetPath(asset),
path: asset,
url: `/api/projects/${projectId}/preview/${asset}`,
};
},
[fileTree, projectId, importedFontAssetsRef],
);
const reportedUnresolvableRef = useRef(new Set<string>());
// fallow-ignore-next-line complexity
const persistDomEditOperations: PersistDomEditOperations = useCallback(
// fallow-ignore-next-line complexity
async (selection, operations, options) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
if (options?.shouldSave && !options.shouldSave()) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) {
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
}
const readData = (await readResponse.json()) as { content?: string };
const originalContent = readData.content;
if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
if (options?.shouldSave && !options.shouldSave()) return;
const patchTarget = buildDomEditPatchTarget(selection);
const patchBody = { target: patchTarget, operations };
const unsafeFields = findUnsafeDomPatchValues(patchBody);
if (unsafeFields.length > 0) {
const fields = formatUnsafeFieldList(unsafeFields);
showToast("Couldn't save edit because it contains invalid layout values", "error");
throw new Error(`DOM patch contains unsafe values: ${fields}`);
}
// Mark the save timestamp before the file write so the SSE file-change
// handler suppresses the reload even if the event arrives before the
// response (the server writes the file and emits SSE during the fetch).
domEditSaveTimestampRef.current = Date.now();
const patchResponse = await fetch(
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchBody),
},
);
if (!patchResponse.ok) {
showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
}
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
matched?: boolean;
content?: string;
};
if (!patchData.changed) {
if (patchData.matched === false) {
const targetKey = selection.selector ?? selection.id ?? "selection";
if (!reportedUnresolvableRef.current.has(targetKey)) {
reportedUnresolvableRef.current.add(targetKey);
trackStudioEvent("save_skipped_unresolvable", {
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
composition: activeCompPath ?? undefined,
});
console.warn(
`[studio] Element not found in source: ${targetKey}. ` +
"This element may be generated at runtime and cannot be persisted.",
);
}
}
return;
}
const patchedContent =
typeof patchData.content === "string" ? patchData.content : originalContent;
let finalContent = patchedContent;
if (options?.prepareContent) {
finalContent = options.prepareContent(patchedContent, targetPath);
if (finalContent !== patchedContent) {
await writeProjectFile(targetPath, finalContent);
}
}
await editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
onDomEditPersisted?.(selection, operations);
if (!options?.skipRefresh) {
reloadPreview();
}
},
[
activeCompPath,
editHistory,
writeProjectFile,
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
onDomEditPersisted,
],
);
// ── Text & style commits (delegated to useDomEditTextCommits) ──
const {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
} = useDomEditTextCommits({
activeCompPath,
previewIframeRef,
domEditSelection,
applyDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
persistDomEditOperations,
resolveImportedFontAsset,
});
// ── Position patch helper (shared by geometry + lifecycle hooks) ──
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
});
// ── Geometry commits (path offset, box size, rotation) ──
const {
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
} = useDomGeometryCommits({
previewIframeRef,
showToast,
commitPositionPatchToHtml,
});
// ── Element lifecycle (delete, z-index reorder) ──
const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({
activeCompPath,
showToast,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
projectIdRef,
reloadPreview,
clearDomSelection,
commitPositionPatchToHtml,
onElementDeleted,
});
return {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomEditElementDelete,
handleDomZIndexReorderCommit,
};
}