fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)

* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
This commit is contained in:
Miguel Ángel
2026-06-12 22:02:17 -04:00
committed by GitHub
parent 84a56986c6
commit 5f12e692d5
19 changed files with 1122 additions and 285 deletions
+29 -39
View File
@@ -6,6 +6,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import {
buildDomEditPatchTarget,
getDomEditTargetKey,
@@ -31,8 +32,10 @@ import {
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Helpers ──
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
@@ -69,6 +72,8 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
return false;
}
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
@@ -102,6 +107,7 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
@@ -115,6 +121,8 @@ export interface UseDomEditCommitsParams {
) => Promise<DomEditSelection | null>;
}
// ── Hook ──
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
@@ -172,7 +180,9 @@ export function useDomEditCommits({
const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) throw new Error(`Failed to read ${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") {
@@ -196,14 +206,15 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget, operations }),
},
);
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
if (!patchResponse.ok) {
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
}
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
matched?: boolean;
content?: string;
path?: string;
};
if (!patchData.changed) {
@@ -243,7 +254,6 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
showToast(`Updated ${patchData.path ?? targetPath}`, "info");
if (!options?.skipRefresh) {
reloadPreview();
@@ -256,7 +266,6 @@ export function useDomEditCommits({
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
],
);
@@ -282,38 +291,12 @@ export function useDomEditCommits({
resolveImportedFontAsset,
});
// ── Position patch helper ──
// fallow-ignore-next-line complexity
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => {
return queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
// fallow-ignore-next-line complexity
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
trackStudioEvent("save_failure", {
source: "dom_edit",
label: options.label,
error_message: message,
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
});
throw error;
});
},
[persistDomEditOperations, queueDomEditSave, showToast],
);
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
});
// ── Position commits ──
@@ -426,7 +409,9 @@ export function useDomEditCommits({
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
if (!response.ok) {
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
@@ -447,7 +432,12 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
if (!removeResponse.ok) {
throw await createStudioSaveHttpError(
removeResponse,
`Failed to delete element from ${targetPath}`,
);
}
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
const patchedContent =