Files
hyperframes/packages/studio/src/hooks/useDomEditCommits.ts
T
James Russo 2417293dab fix(studio): enforce optimistic file concurrency (#2156)
* fix(studio): enforce optimistic file concurrency

* fix(studio): harden conditional file writes

* fix(studio): honor explicit file preconditions

* test(producer): allow zero-ms encode timing
2026-07-15 18:07:04 -04:00

455 lines
17 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,
StudioSaveHttpError,
trackStudioSaveFailure,
} 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 { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
import type { PatchOperation } from "../utils/sourcePatcher";
import {
DomEditPersistUnsafeValueError,
DomEditPersistUnresolvableError,
warnDomEditPersistNoOp,
} from "./domEditPersistFailure";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useElementLifecycleOps } from "./useElementLifecycleOps";
import {
AtomicElementPatchConvergenceError,
batchesAreInlineStyleOnly,
formatPatchRejectionMessage,
formatUnsafeFieldList,
getErrorDetail,
patchElementBatches,
readErrorResponseBody,
} from "./useDomEditCommitsHelpers";
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
coalesceMs?: number;
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: <T>(save: () => Promise<T>) => Promise<T>;
writeProjectFile: (path: string, content: string, expectedContent?: 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>;
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
* path, whose session is already current) so a later SDK edit doesn't
* serialize the pre-write doc and revert the server's change. */
forceReloadSdkSession?: () => void;
/** Stage 7 Step 3c: called before the server-side patch path. */
onTrySdkPersist?: (
selection: DomEditSelection,
operations: PatchOperation[],
originalContent: string,
targetPath: string,
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
) => Promise<CutoverResult>;
/** Stage 7 §3.1: called before the server-side delete path. */
onTrySdkDelete?: (
hfId: string,
originalContent: string,
targetPath: string,
) => Promise<CutoverResult>;
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
onReorderShadow?: (targets: string[]) => void;
}
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
queueDomEditSave,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectId,
projectIdRef,
reloadPreview,
domEditSelection,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
forceReloadSdkSession,
onTrySdkPersist,
onTrySdkDelete,
onReorderShadow,
}: 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;
// Validate layout values BEFORE any persist path runs. The SDK cutover
// path (onTrySdkPersist) returns early on success, so leaving this check
// after it let invalid numeric values bypass the guard whenever the
// cutover flag was on.
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 DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
alreadyToasted: true,
});
}
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
// for a custom font): sdkCutoverPersist serializes only the patched DOM
// and would drop the injected content. Let the server path run prepareContent.
if (onTrySdkPersist && !options?.prepareContent) {
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
label: options?.label,
coalesceKey: options?.coalesceKey,
skipRefresh: options?.skipRefresh,
});
if (cutoverCommittedOrThrow(cutover)) {
// SDK handled it — its in-memory doc is already current, so do NOT
// forceReload (that would echo-reload the session we just wrote).
return;
}
}
// 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}`, {
alreadyToasted: true,
});
}
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,
});
}
throw new DomEditPersistUnresolvableError(targetPath);
}
warnDomEditPersistNoOp(selection, operations);
return;
}
const patchedContent =
typeof patchData.content === "string" ? patchData.content : originalContent;
let finalContent = patchedContent;
if (options?.prepareContent) {
const preparedContent = options.prepareContent(patchedContent, targetPath);
if (preparedContent !== patchedContent) {
try {
await writeProjectFile(targetPath, preparedContent, patchedContent);
finalContent = preparedContent;
} catch (error) {
// The patch above already landed on disk — only the prepareContent
// embellishment (e.g. an injected @font-face) failed to write. Keep
// the already-persisted patchedContent instead of throwing, which
// would otherwise revert a change the server already committed.
showToast(
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
"error",
);
}
}
}
await editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
coalesceKey: options?.coalesceKey,
coalesceMs: options?.coalesceMs,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
forceReloadSdkSession?.();
if (!options?.skipRefresh) {
reloadPreview();
}
},
[
activeCompPath,
editHistory,
writeProjectFile,
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
forceReloadSdkSession,
onTrySdkPersist,
],
);
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
(batches, options) =>
queueDomEditSave(
// One queued transaction owns validation, persistence, history, reload,
// and its durable result; splitting those phases risks partial commits.
// fallow-ignore-next-line complexity
async () => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const unsafeFields = batches.flatMap((batch) =>
batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
);
if (unsafeFields.length > 0) {
showToast("Couldn't save edit because it contains invalid layout values", "error");
throw new DomEditPersistUnsafeValueError(
`DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
{ alreadyToasted: true },
);
}
domEditSaveTimestampRef.current = Date.now();
const atomicResult = await patchElementBatches(pid, batches);
const allMatched =
atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
const files = Object.fromEntries(
atomicResult.files
.filter((result) => result.changed)
.map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
);
const changed = Object.keys(files).length > 0;
if (changed) {
await editHistory.recordEdit({
label: options.label,
kind: "manual",
coalesceKey: options.coalesceKey,
coalesceMs: options.coalesceMs,
files,
});
forceReloadSdkSession?.();
}
const durable = allMatched;
// A z-only reorder already applied its inline styles to the live iframe
// DOM (and the store) synchronously, so remounting the iframe here only
// produces a visible blink. Skip the reload when the caller asked for it
// AND the persist is provably in sync: style-only ops, every target
// matched. Any unmatched patch means the live DOM now shows state disk
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
// reload is independently suppressed by domEditSaveTimestampRef above.)
const skipSafe =
options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable;
if (!durable || (changed && !skipSafe)) reloadPreview();
return { durable, allMatched, changed };
},
).catch((error) => {
if (error instanceof AtomicElementPatchConvergenceError) reloadPreview();
const alreadyToasted =
(error instanceof StudioSaveHttpError ||
error instanceof DomEditPersistUnsafeValueError) &&
error.alreadyToasted;
if (!alreadyToasted) {
showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
}
trackStudioSaveFailure({
source: "dom_edit",
error,
filePath: batches.map((batch) => batch.sourceFile).join(","),
mutationType: "z-reorder",
label: options.label,
});
throw error;
}),
[
domEditSaveTimestampRef,
editHistory,
forceReloadSdkSession,
projectIdRef,
queueDomEditSave,
reloadPreview,
showToast,
],
);
// ── Text & style commits (delegated to useDomEditTextCommits) ──
const {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
} = useDomEditTextCommits({
activeCompPath,
previewIframeRef,
domEditSelection,
applyDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
persistDomEditOperations,
resolveImportedFontAsset,
showToast,
});
// ── Position patch helper (shared by geometry + lifecycle hooks) ──
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
});
// ── Geometry commits (path offset, box size, rotation) ──
const {
handleDomPathOffsetCommit,
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,
onTrySdkDelete,
onReorderShadow,
forceReloadSdkSession,
commitDomEditPatchBatches,
});
return {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomEditElementDelete,
handleDomZIndexReorderCommit,
};
}