diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index 2f944b228..b221ad56f 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { LintModal } from "./components/LintModal";
+import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -481,6 +482,13 @@ export function StudioApp() {
onExport={() => void renderQueue.startRender()}
/>
+ {previewPersistence.domEditSaveQueuePaused && (
+
+ )}
+
void;
+}
+
+/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
+export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
+ return (
+
+ {message}
+
+
+ );
+}
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index 2cadbd2d8..2694a194b 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -20,6 +20,7 @@ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEdi
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
+import { useAnimatedPropertyCommitTelemetry } from "../../hooks/useAnimatedPropertyCommitTelemetry";
// Re-export helpers that external consumers import from this module
export {
@@ -111,6 +112,8 @@ export const PropertyPanel = memo(function PropertyPanel({
const currentTime = isPlaying ? liveTimeRef.current : storeTime;
const cacheElementKey = element?.id ?? element?.selector ?? "";
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
+ const { commitAnimatedPropertySafely, commitAnimatedPropertyWithTelemetry } =
+ useAnimatedPropertyCommitTelemetry(onCommitAnimatedProperty);
if (!element) {
return (
@@ -163,7 +166,7 @@ export const PropertyPanel = memo(function PropertyPanel({
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
- void onCommitAnimatedProperty(element, axis, parsed);
+ commitAnimatedPropertySafely(element, axis, parsed);
return;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -176,10 +179,12 @@ export const PropertyPanel = memo(function PropertyPanel({
return;
}
const current = readStudioPathOffset(element.element);
- onSetManualOffset(element, {
- x: axis === "x" ? parsed : current.x,
- y: axis === "y" ? parsed : current.y,
- });
+ void Promise.resolve(
+ onSetManualOffset(element, {
+ x: axis === "x" ? parsed : current.x,
+ y: axis === "y" ? parsed : current.y,
+ }),
+ ).catch(() => {});
};
// fallow-ignore-next-line complexity
@@ -187,7 +192,7 @@ export const PropertyPanel = memo(function PropertyPanel({
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
- void onCommitAnimatedProperty(element, axis, parsed);
+ commitAnimatedPropertySafely(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
@@ -203,17 +208,19 @@ export const PropertyPanel = memo(function PropertyPanel({
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
- onSetManualSize(element, {
- width: axis === "width" ? parsed : width,
- height: axis === "height" ? parsed : height,
- });
+ void Promise.resolve(
+ onSetManualSize(element, {
+ width: axis === "width" ? parsed : width,
+ height: axis === "height" ? parsed : height,
+ }),
+ ).catch(() => {});
};
const manualRotation = readStudioRotation(element.element);
const commitManualRotation = (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
- onSetManualRotation(element, { angle: parsed });
+ void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => {});
};
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
@@ -392,8 +399,7 @@ export const PropertyPanel = memo(function PropertyPanel({
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
- onCommitAnimatedProperty &&
- void onCommitAnimatedProperty(element, "x", displayX)
+ onCommitAnimatedProperty && commitAnimatedPropertySafely(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -417,8 +423,7 @@ export const PropertyPanel = memo(function PropertyPanel({
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
- onCommitAnimatedProperty &&
- void onCommitAnimatedProperty(element, "y", displayY)
+ onCommitAnimatedProperty && commitAnimatedPropertySafely(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -443,7 +448,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
- void onCommitAnimatedProperty(element, "width", displayW)
+ commitAnimatedPropertySafely(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -468,7 +473,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
- void onCommitAnimatedProperty(element, "height", displayH)
+ commitAnimatedPropertySafely(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -491,7 +496,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
- void onCommitAnimatedProperty(element, "rotation", displayR)
+ commitAnimatedPropertySafely(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -508,7 +513,9 @@ export const PropertyPanel = memo(function PropertyPanel({
elStart={elStart}
elDuration={elDuration}
element={element}
- onCommitAnimatedProperty={onCommitAnimatedProperty}
+ onCommitAnimatedProperty={
+ onCommitAnimatedProperty ? commitAnimatedPropertyWithTelemetry : undefined
+ }
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts
index f600e5d2f..5fae24d91 100644
--- a/packages/studio/src/components/editor/propertyPanelHelpers.ts
+++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts
@@ -15,9 +15,15 @@ export interface PropertyPanelProps {
onSetStyle: (prop: string, value: string) => void | Promise;
onSetAttribute: (attr: string, value: string) => void | Promise;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise;
- onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
- onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
- onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
+ onSetManualOffset: (
+ element: DomEditSelection,
+ next: { x: number; y: number },
+ ) => void | Promise;
+ onSetManualSize: (
+ element: DomEditSelection,
+ next: { width: number; height: number },
+ ) => void | Promise;
+ onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise | null;
diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
index a25064e5e..9af7d18e2 100644
--- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
+++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
@@ -30,7 +30,7 @@ interface CommitAnimatedPropertyDeps {
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
currentTime?: number,
- ) => void;
+ ) => Promise;
convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
previewIframeRef: React.RefObject;
bumpGsapCache: () => void;
@@ -106,7 +106,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// Case 3: No animation — create one first
if (!anim) {
- addGsapAnimation(selection, "to");
+ await addGsapAnimation(selection, "to");
// The addGsapAnimation triggers a reload. We need to wait for the cache
// to update. Use a small delay then bump cache to re-fetch.
await new Promise((r) => setTimeout(r, 500));
diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommitTelemetry.ts b/packages/studio/src/hooks/useAnimatedPropertyCommitTelemetry.ts
new file mode 100644
index 000000000..d0e6b0899
--- /dev/null
+++ b/packages/studio/src/hooks/useAnimatedPropertyCommitTelemetry.ts
@@ -0,0 +1,47 @@
+import { useCallback } from "react";
+import { useStudioContext } from "../contexts/StudioContext";
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
+
+type CommitAnimatedProperty = (
+ selection: DomEditSelection,
+ property: string,
+ value: number,
+) => Promise;
+
+export function useAnimatedPropertyCommitTelemetry(
+ onCommitAnimatedProperty: CommitAnimatedProperty | undefined,
+) {
+ const { showToast } = useStudioContext();
+
+ const commitAnimatedPropertyWithTelemetry = useCallback(
+ async (selection: DomEditSelection, property: string, value: number) => {
+ if (!onCommitAnimatedProperty) return;
+ try {
+ await onCommitAnimatedProperty(selection, property, value);
+ } catch (error) {
+ trackStudioSaveFailure({
+ source: "animated_property",
+ error,
+ filePath: selection.sourceFile ?? undefined,
+ mutationType: property,
+ label: `Edit ${property}`,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ showToast?.("Failed to save animated property.", "error");
+ }
+ },
+ [onCommitAnimatedProperty, showToast],
+ );
+
+ const commitAnimatedPropertySafely = useCallback(
+ (selection: DomEditSelection, property: string, value: number) => {
+ void commitAnimatedPropertyWithTelemetry(selection, property, value);
+ },
+ [commitAnimatedPropertyWithTelemetry],
+ );
+
+ return { commitAnimatedPropertySafely, commitAnimatedPropertyWithTelemetry };
+}
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts
index 6695ccf85..bbe26e639 100644
--- a/packages/studio/src/hooks/useAppHotkeys.ts
+++ b/packages/studio/src/hooks/useAppHotkeys.ts
@@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
@@ -222,6 +223,39 @@ export function useAppHotkeys({
const onToggleRecordingRef = useRef(onToggleRecording);
onToggleRecordingRef.current = onToggleRecording;
+ const runUndoRedoWithTelemetry = useCallback(
+ async (action: "undo" | "redo", run: () => Promise) => {
+ try {
+ await run();
+ } catch (error) {
+ trackStudioSaveFailure({
+ source: "undo_redo",
+ error,
+ mutationType: action,
+ label: action === "undo" ? "Undo" : "Redo",
+ });
+ showToast(`Failed to ${action}.`, "error");
+ }
+ },
+ [showToast],
+ );
+
+ const runUndoRedoHotkey = useCallback(
+ (action: "undo" | "redo", run: () => Promise) => {
+ void runUndoRedoWithTelemetry(action, run);
+ },
+ [runUndoRedoWithTelemetry],
+ );
+
+ const handleUndoWithTelemetry = useCallback(
+ () => runUndoRedoWithTelemetry("undo", handleUndo),
+ [handleUndo, runUndoRedoWithTelemetry],
+ );
+ const handleRedoWithTelemetry = useCallback(
+ () => runUndoRedoWithTelemetry("redo", handleRedo),
+ [handleRedo, runUndoRedoWithTelemetry],
+ );
+
// ── Consolidated keydown handler ──
handleAppKeyDownRef.current = (event: KeyboardEvent) => {
@@ -234,8 +268,8 @@ export function useAppHotkeys({
!shouldIgnoreHistoryShortcut(event.target) &&
handleUndoRedoKey(
event,
- () => void handleUndoRef.current(),
- () => void handleRedoRef.current(),
+ () => runUndoRedoHotkey("undo", handleUndoRef.current),
+ () => runUndoRedoHotkey("redo", handleRedoRef.current),
)
) {
return;
@@ -498,15 +532,18 @@ export function useAppHotkeys({
// ── History hotkey for iframe forwarding ──
- const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
- if (!(event.metaKey || event.ctrlKey)) return;
- if (shouldIgnoreHistoryShortcut(event.target)) return;
- handleUndoRedoKey(
- event,
- () => void handleUndoRef.current(),
- () => void handleRedoRef.current(),
- );
- }, []);
+ const handleHistoryHotkey = useCallback(
+ (event: KeyboardEvent) => {
+ if (!(event.metaKey || event.ctrlKey)) return;
+ if (shouldIgnoreHistoryShortcut(event.target)) return;
+ handleUndoRedoKey(
+ event,
+ () => runUndoRedoHotkey("undo", handleUndoRef.current),
+ () => runUndoRedoHotkey("redo", handleRedoRef.current),
+ );
+ },
+ [runUndoRedoHotkey],
+ );
const syncPreviewHistoryHotkey = useCallback(
(iframe: HTMLIFrameElement | null) => {
@@ -549,8 +586,8 @@ export function useAppHotkeys({
);
return {
- handleUndo,
- handleRedo,
+ handleUndo: handleUndoWithTelemetry,
+ handleRedo: handleRedoWithTelemetry,
syncPreviewTimelineHotkey,
syncPreviewHistoryHotkey,
handleTimelineToggleHotkey,
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 8c59b69e5..eedd43f1a 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -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,
@@ -39,6 +40,7 @@ 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 ──
@@ -183,7 +185,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") {
@@ -207,7 +211,9 @@ 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;
@@ -290,37 +296,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 },
- ) => {
- void 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,
- });
- });
- },
- [persistDomEditOperations, queueDomEditSave, showToast],
- );
+ const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
+ activeCompPath,
+ persistDomEditOperations,
+ queueDomEditSave,
+ showToast,
+ });
// ── Position commits ──
@@ -471,7 +452,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;
@@ -492,7 +475,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 =
diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
new file mode 100644
index 000000000..57abe718a
--- /dev/null
+++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
@@ -0,0 +1,50 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditing";
+import type { PatchOperation } from "../utils/sourcePatcher";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
+import type { PersistDomEditOperations } from "./useDomEditCommits";
+
+interface UseDomEditPositionPatchCommitParams {
+ activeCompPath: string | null;
+ persistDomEditOperations: PersistDomEditOperations;
+ queueDomEditSave: (save: () => Promise) => Promise;
+ showToast: (message: string, tone?: "error" | "info") => void;
+}
+
+interface PositionPatchOptions {
+ label: string;
+ coalesceKey: string;
+ skipRefresh?: boolean;
+}
+
+export function useDomEditPositionPatchCommit({
+ activeCompPath,
+ persistDomEditOperations,
+ queueDomEditSave,
+ showToast,
+}: UseDomEditPositionPatchCommitParams) {
+ return useCallback(
+ (selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
+ void queueDomEditSave(async () => {
+ await persistDomEditOperations(selection, patches, {
+ label: options.label,
+ coalesceKey: options.coalesceKey,
+ skipRefresh: options.skipRefresh ?? true,
+ });
+ }).catch((error) => {
+ showToast(error instanceof Error ? error.message : "Failed to save position");
+ trackStudioSaveFailure({
+ source: "dom_edit",
+ error,
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
+ mutationType: "position",
+ label: options.label,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ });
+ },
+ [activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
+ );
+}
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index a191452d9..bd3417932 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -21,8 +21,6 @@ import {
useGsapAnimationsForElement,
useGsapCacheVersion,
usePopulateKeyframeCacheForFile,
- fetchParsedAnimations,
- getAnimationsForElement,
} from "./useGsapTweenCache";
import {
tryGsapDragIntercept,
@@ -30,6 +28,8 @@ import {
tryGsapRotationIntercept,
} from "./gsapRuntimeBridge";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
+import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
+import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
// ── Types ──
@@ -326,26 +326,28 @@ export function useDomEditSession({
buildDomSelectionFromTarget,
});
+ const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
+
+ const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile);
+
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
const handleGsapAwarePathOffsetCommit = useCallback(
async (selection: DomEditSelection, next: { x: number; y: number }) => {
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
- const handled = await tryGsapDragIntercept(
- selection,
- next,
- selectedGsapAnimations,
- previewIframeRef.current,
- gsapCommitMutation,
- async () => {
- const pid = projectId;
- if (!pid) return [];
- const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
- if (!parsed) return [];
- const target = { id: selection.id ?? null, selector: selection.selector ?? null };
- return getAnimationsForElement(parsed.animations, target);
- },
- );
- if (handled) return;
+ try {
+ const handled = await tryGsapDragIntercept(
+ selection,
+ next,
+ selectedGsapAnimations,
+ previewIframeRef.current,
+ gsapCommitMutation,
+ makeFetchFallback(selection),
+ );
+ if (handled) return;
+ } catch (error) {
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
+ throw error;
+ }
}
handleDomPathOffsetCommit(selection, next);
},
@@ -354,37 +356,28 @@ export function useDomEditSession({
selectedGsapAnimations,
gsapCommitMutation,
previewIframeRef,
- projectId,
- gsapSourceFile,
+ makeFetchFallback,
+ trackGsapInteractionFailure,
],
);
- const makeFetchFallback = useCallback(
- (selection: DomEditSelection) => async () => {
- const pid = projectId;
- if (!pid) return [];
- const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
- if (!parsed) return [];
- return getAnimationsForElement(parsed.animations, {
- id: selection.id ?? null,
- selector: selection.selector ?? null,
- });
- },
- [projectId, gsapSourceFile],
- );
-
const handleGsapAwareBoxSizeCommit = useCallback(
async (selection: DomEditSelection, next: { width: number; height: number }) => {
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
- const handled = await tryGsapResizeIntercept(
- selection,
- next,
- selectedGsapAnimations,
- previewIframeRef.current,
- gsapCommitMutation,
- makeFetchFallback(selection),
- );
- if (handled) return;
+ try {
+ const handled = await tryGsapResizeIntercept(
+ selection,
+ next,
+ selectedGsapAnimations,
+ previewIframeRef.current,
+ gsapCommitMutation,
+ makeFetchFallback(selection),
+ );
+ if (handled) return;
+ } catch (error) {
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
+ throw error;
+ }
}
handleDomBoxSizeCommit(selection, next);
},
@@ -394,21 +387,27 @@ export function useDomEditSession({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
+ trackGsapInteractionFailure,
],
);
const handleGsapAwareRotationCommit = useCallback(
async (selection: DomEditSelection, next: { angle: number }) => {
if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
- const handled = await tryGsapRotationIntercept(
- selection,
- next.angle,
- selectedGsapAnimations,
- previewIframeRef.current,
- gsapCommitMutation,
- makeFetchFallback(selection),
- );
- if (handled) return;
+ try {
+ const handled = await tryGsapRotationIntercept(
+ selection,
+ next.angle,
+ selectedGsapAnimations,
+ previewIframeRef.current,
+ gsapCommitMutation,
+ makeFetchFallback(selection),
+ );
+ if (handled) return;
+ } catch (error) {
+ trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
+ throw error;
+ }
}
handleDomRotationCommit(selection, next);
},
@@ -418,6 +417,7 @@ export function useDomEditSession({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
+ trackGsapInteractionFailure,
],
);
diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts
index d5e12669e..d62a19fae 100644
--- a/packages/studio/src/hooks/useDomEditTextCommits.ts
+++ b/packages/studio/src/hooks/useDomEditTextCommits.ts
@@ -23,6 +23,7 @@ import {
} from "../components/editor/domEditing";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { PersistDomEditOperations } from "./useDomEditCommits";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
// ── Types ──
@@ -57,6 +58,22 @@ export function useDomEditTextCommits({
}: UseDomEditTextCommitsParams) {
const domTextCommitVersionRef = useRef(0);
+ const trackDomEditSaveFailure = useCallback(
+ (error: unknown, mutationType: string, selection: DomEditSelection, label?: string) => {
+ trackStudioSaveFailure({
+ source: "dom_edit",
+ error,
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
+ mutationType,
+ label,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ },
+ [activeCompPath],
+ );
+
const handleDomStyleCommit = useCallback(
async (property: string, value: string) => {
if (!domEditSelection) return;
@@ -100,7 +117,7 @@ export function useDomEditTextCommits({
: undefined,
});
} catch (err) {
- console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
+ trackDomEditSaveFailure(err, "style", domEditSelection, "Edit layer style");
}
refreshDomEditSelectionFromPreview(domEditSelection);
},
@@ -111,6 +128,7 @@ export function useDomEditTextCommits({
refreshDomEditSelectionFromPreview,
resolveImportedFontAsset,
previewIframeRef,
+ trackDomEditSaveFailure,
],
);
@@ -131,9 +149,11 @@ export function useDomEditTextCommits({
skipRefresh: false,
});
} catch (err) {
- console.warn(
- "[Studio] Attribute persist failed:",
- err instanceof Error ? err.message : err,
+ trackDomEditSaveFailure(
+ err,
+ "data_attribute",
+ domEditSelection,
+ `Edit ${attr.replace(/-/g, " ")}`,
);
}
refreshDomEditSelectionFromPreview(domEditSelection);
@@ -144,6 +164,7 @@ export function useDomEditTextCommits({
persistDomEditOperations,
refreshDomEditSelectionFromPreview,
previewIframeRef,
+ trackDomEditSaveFailure,
],
);
@@ -170,10 +191,7 @@ export function useDomEditTextCommits({
skipRefresh: false,
});
} catch (err) {
- console.warn(
- "[Studio] HTML attribute persist failed:",
- err instanceof Error ? err.message : err,
- );
+ trackDomEditSaveFailure(err, "html_attribute", domEditSelection, `Edit ${attr}`);
}
refreshDomEditSelectionFromPreview(domEditSelection);
},
@@ -183,6 +201,7 @@ export function useDomEditTextCommits({
persistDomEditOperations,
refreshDomEditSelectionFromPreview,
previewIframeRef,
+ trackDomEditSaveFailure,
],
);
@@ -217,15 +236,20 @@ export function useDomEditTextCommits({
}
}
}
- await persistDomEditOperations(
- domEditSelection,
- [buildDomEditTextPatchOperation(nextContent)],
- {
- label: "Edit text",
- skipRefresh: true,
- shouldSave: () => domTextCommitVersionRef.current === commitVersion,
- },
- );
+ try {
+ await persistDomEditOperations(
+ domEditSelection,
+ [buildDomEditTextPatchOperation(nextContent)],
+ {
+ label: "Edit text",
+ skipRefresh: true,
+ shouldSave: () => domTextCommitVersionRef.current === commitVersion,
+ },
+ );
+ } catch (err) {
+ trackDomEditSaveFailure(err, "text", domEditSelection, "Edit text");
+ return;
+ }
if (domTextCommitVersionRef.current !== commitVersion) return;
if (doc) {
@@ -245,6 +269,7 @@ export function useDomEditTextCommits({
domEditSelection,
persistDomEditOperations,
previewIframeRef,
+ trackDomEditSaveFailure,
],
);
@@ -339,7 +364,11 @@ export function useDomEditTextCommits({
: entry,
);
- await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
+ try {
+ await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
+ } catch (err) {
+ trackDomEditSaveFailure(err, "text_field_style", domEditSelection, "Edit text style");
+ }
},
[
commitDomTextFields,
@@ -347,6 +376,7 @@ export function useDomEditTextCommits({
handleDomStyleCommit,
resolveImportedFontAsset,
previewIframeRef,
+ trackDomEditSaveFailure,
],
);
@@ -369,10 +399,15 @@ export function useDomEditTextCommits({
nextField,
);
- await commitDomTextFields(domEditSelection, nextTextFields);
+ try {
+ await commitDomTextFields(domEditSelection, nextTextFields);
+ } catch (err) {
+ trackDomEditSaveFailure(err, "text_field_add", domEditSelection, "Add text");
+ return null;
+ }
return nextField.key;
},
- [commitDomTextFields, domEditSelection],
+ [commitDomTextFields, domEditSelection, trackDomEditSaveFailure],
);
const handleDomRemoveTextField = useCallback(
@@ -387,9 +422,13 @@ export function useDomEditTextCommits({
}
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
- await commitDomTextFields(domEditSelection, nextTextFields);
+ try {
+ await commitDomTextFields(domEditSelection, nextTextFields);
+ } catch (err) {
+ trackDomEditSaveFailure(err, "text_field_remove", domEditSelection, "Remove text");
+ }
},
- [commitDomTextFields, domEditSelection, handleDomTextCommit],
+ [commitDomTextFields, domEditSelection, handleDomTextCommit, trackDomEditSaveFailure],
);
return {
diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts
index f977d8bd9..6e06cb0db 100644
--- a/packages/studio/src/hooks/useFileManager.ts
+++ b/packages/studio/src/hooks/useFileManager.ts
@@ -5,7 +5,12 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
-import { trackStudioEvent } from "../utils/studioTelemetry";
+import {
+ createStudioSaveHttpError,
+ isStudioSaveAbortError,
+ retryStudioSave,
+ trackStudioSaveFailure,
+} from "../utils/studioSaveDiagnostics";
// ── Types ──
@@ -53,6 +58,21 @@ export function useFileManager({
const saveRafRef = useRef(null);
const refreshRafRef = useRef(null);
const importedFontAssetsRef = useRef([]);
+ const mountedRef = useRef(true);
+ const saveAbortControllersRef = useRef(new Set());
+
+ useEffect(
+ () => () => {
+ mountedRef.current = false;
+ if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
+ if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
+ for (const controller of saveAbortControllersRef.current) {
+ controller.abort();
+ }
+ saveAbortControllersRef.current.clear();
+ },
+ [],
+ );
// ── Load file tree when projectId changes ──
@@ -88,7 +108,7 @@ export function useFileManager({
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
- if (!response.ok) throw new Error(`Failed to read ${path}`);
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
return data.content;
@@ -97,13 +117,30 @@ export function useFileManager({
const writeProjectFile = useCallback(async (path: string, content: string): Promise => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
- method: "PUT",
- headers: { "Content-Type": "text/plain" },
- body: content,
- });
- if (!response.ok) throw new Error(`Failed to save ${path}`);
- if (editingPathRef.current === path) {
+ const controller = new AbortController();
+ saveAbortControllersRef.current.add(controller);
+ if (!mountedRef.current) controller.abort();
+
+ try {
+ await retryStudioSave(
+ async () => {
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "text/plain" },
+ body: content,
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
+ }
+ },
+ { signal: controller.signal },
+ );
+ } finally {
+ saveAbortControllersRef.current.delete(controller);
+ }
+
+ if (mountedRef.current && editingPathRef.current === path) {
setEditingFile({ path, content });
}
}, []);
@@ -120,7 +157,7 @@ export function useFileManager({
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(path)}?optional=1`,
);
- if (!response.ok) throw new Error(`Failed to read ${path}`);
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
const data = (await response.json()) as { content?: string };
return typeof data.content === "string" ? data.content : "";
}, []);
@@ -175,14 +212,27 @@ export function useFileManager({
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
})
.catch((error) => {
- trackStudioEvent("save_failure", {
+ if (isStudioSaveAbortError(error)) return;
+ trackStudioSaveFailure({
source: "code_editor",
- error_message: error instanceof Error ? error.message : "unknown",
+ error,
+ filePath: path,
+ mutationType: "put",
});
+ if (mountedRef.current) {
+ showToast("Failed to save source changes. Check your connection.", "error");
+ }
});
});
},
- [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
+ [
+ domEditSaveTimestampRef,
+ readProjectFile,
+ recordEdit,
+ setRefreshKey,
+ showToast,
+ writeProjectFile,
+ ],
);
// ── Open source for selection (click-to-source) ──
diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts
new file mode 100644
index 000000000..f995d0ee6
--- /dev/null
+++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts
@@ -0,0 +1,19 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditing";
+import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
+
+export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
+ return useCallback(
+ (selection: DomEditSelection) => async () => {
+ const pid = projectId;
+ if (!pid) return [];
+ const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
+ if (!parsed) return [];
+ return getAnimationsForElement(parsed.animations, {
+ id: selection.id ?? null,
+ selector: selection.selector ?? null,
+ });
+ },
+ [projectId, gsapSourceFile],
+ );
+}
diff --git a/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts
new file mode 100644
index 000000000..e451c3a91
--- /dev/null
+++ b/packages/studio/src/hooks/useGsapInteractionFailureTelemetry.ts
@@ -0,0 +1,25 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditing";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
+
+export function useGsapInteractionFailureTelemetry(
+ activeCompPath: string | null,
+ showToast: (message: string, tone?: "error" | "info") => void,
+) {
+ return useCallback(
+ (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
+ trackStudioSaveFailure({
+ source: "gsap_commit",
+ error,
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
+ mutationType,
+ label,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ showToast("Failed to save animated edit.", "error");
+ },
+ [activeCompPath, showToast],
+ );
+}
diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts
index 70eee5215..3ea527ccf 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -11,6 +11,11 @@ import {
readKeyframeSnapshot,
writeKeyframeCache,
} from "./gsapKeyframeCacheHelpers";
+import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
+import {
+ useGsapSaveFailureTelemetry,
+ useSafeGsapCommitMutation,
+} from "./useSafeGsapCommitMutation";
const PROPERTY_DEFAULTS: Record = {
opacity: 1,
@@ -61,21 +66,23 @@ async function mutateGsapScript(
projectId: string,
sourceFile: string,
mutation: Record,
-): Promise {
- try {
- const res = await fetch(
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(mutation),
- },
- );
- if (!res.ok) return null;
- return (await res.json()) as MutationResult;
- } catch {
- return null;
+): Promise {
+ const res = await fetch(
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(mutation),
+ },
+ );
+ if (!res.ok) {
+ throw await createStudioSaveHttpError(res, `Failed to update GSAP in ${sourceFile}`);
}
+ const result = (await res.json()) as MutationResult;
+ if (!result.ok) {
+ throw new Error(`Failed to update GSAP in ${sourceFile}`);
+ }
+ return result;
}
interface GsapScriptCommitsParams {
projectIdRef: React.MutableRefObject;
@@ -133,7 +140,6 @@ export function useGsapScriptCommits({
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const result = await mutateGsapScript(pid, targetPath, mutation);
- if (!result?.ok) return;
domEditSaveTimestampRef.current = Date.now();
@@ -189,12 +195,16 @@ export function useGsapScriptCommits({
onFileContentChanged,
],
);
+
+ const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
+ const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure);
+
const flushPendingPropertyEdit = useCallback(() => {
const pending = pendingPropertyEditRef.current;
if (!pending) return;
pendingPropertyEditRef.current = null;
const { selection, animationId, property, value } = pending;
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-property", animationId, property, value },
{
@@ -203,7 +213,7 @@ export function useGsapScriptCommits({
softReload: true,
},
);
- }, [commitMutation]);
+ }, [commitMutationSafely]);
const updateGsapProperty = useCallback(
(
@@ -231,7 +241,7 @@ export function useGsapScriptCommits({
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{
@@ -240,17 +250,17 @@ export function useGsapScriptCommits({
},
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const deleteGsapAnimation = useCallback(
(selection: DomEditSelection, animationId: string) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "delete", animationId, stripStudioEdits: true },
{ label: "Delete GSAP animation" },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const addGsapAnimation = useCallback(
// fallow-ignore-next-line complexity
@@ -281,9 +291,16 @@ export function useGsapScriptCommits({
}),
},
);
- if (!res.ok) return;
+ if (!res.ok) {
+ throw await createStudioSaveHttpError(
+ res,
+ `Failed to assign element id in ${targetPath}`,
+ );
+ }
const data = (await res.json()) as { changed?: boolean };
- if (!data.changed) return;
+ if (!data.changed) {
+ throw new Error(`Failed to assign element id in ${targetPath}`);
+ }
}
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
@@ -326,23 +343,23 @@ export function useGsapScriptCommits({
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
}
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "add-property", animationId, property, defaultValue },
{ label: `Add GSAP ${property}` },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const removeGsapProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "remove-property", animationId, property },
{ label: `Remove GSAP ${property}` },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const updateGsapFromProperty = useCallback(
(
@@ -351,7 +368,7 @@ export function useGsapScriptCommits({
property: string,
value: number | string,
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-from-property", animationId, property, value },
{
@@ -360,28 +377,28 @@ export function useGsapScriptCommits({
},
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const addGsapFromProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "add-from-property", animationId, property, defaultValue },
{ label: `Add GSAP from-${property}` },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const removeGsapFromProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "remove-from-property", animationId, property },
{ label: `Remove GSAP from-${property}` },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const addKeyframe = useCallback(
(
@@ -393,6 +410,12 @@ export function useGsapScriptCommits({
) => {
const sf = selection.sourceFile || activeCompPath || "index.html";
const elementId = selection.id;
+ const mutation = {
+ type: "add-keyframe",
+ animationId,
+ percentage,
+ properties: { [property]: value },
+ };
void executeOptimistic({
apply: () => {
const prev = readKeyframeSnapshot(sf, elementId);
@@ -406,17 +429,18 @@ export function useGsapScriptCommits({
return prev;
},
persist: () =>
- commitMutation(
- selection,
- { type: "add-keyframe", animationId, percentage, properties: { [property]: value } },
- { label: `Add keyframe at ${percentage}%`, softReload: true },
- ),
+ commitMutation(selection, mutation, {
+ label: `Add keyframe at ${percentage}%`,
+ softReload: true,
+ }),
rollback: (prev) => {
writeKeyframeCache(sf, elementId, prev);
},
+ }).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
});
},
- [commitMutation, activeCompPath],
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
);
const addKeyframeBatch = useCallback(
(
@@ -437,6 +461,7 @@ export function useGsapScriptCommits({
(selection: DomEditSelection, animationId: string, percentage: number) => {
const sf = selection.sourceFile || activeCompPath || "index.html";
const elementId = selection.id;
+ const mutation = { type: "remove-keyframe", animationId, percentage };
void executeOptimistic({
apply: () => {
const prev = readKeyframeSnapshot(sf, elementId);
@@ -447,17 +472,18 @@ export function useGsapScriptCommits({
return prev;
},
persist: () =>
- commitMutation(
- selection,
- { type: "remove-keyframe", animationId, percentage },
- { label: `Remove keyframe at ${percentage}%`, softReload: true },
- ),
+ commitMutation(selection, mutation, {
+ label: `Remove keyframe at ${percentage}%`,
+ softReload: true,
+ }),
rollback: (prev) => {
writeKeyframeCache(sf, elementId, prev);
},
+ }).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
});
},
- [commitMutation, activeCompPath],
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
);
const convertToKeyframes = useCallback(
(
@@ -475,13 +501,13 @@ export function useGsapScriptCommits({
);
const removeAllKeyframes = useCallback(
(selection: DomEditSelection, animationId: string) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "remove-all-keyframes", animationId },
{ label: "Remove all keyframes", softReload: true },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const setArcPath = useCallback(
(
@@ -497,13 +523,13 @@ export function useGsapScriptCommits({
}>;
},
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "set-arc-path" as const, animationId, ...config },
{ label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const updateArcSegment = useCallback(
(
@@ -516,23 +542,23 @@ export function useGsapScriptCommits({
cp2?: { x: number; y: number };
},
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-arc-segment" as const, animationId, segmentIndex, ...update },
{ label: "Update arc segment", softReload: true },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const removeArcPath = useCallback(
(selection: DomEditSelection, animationId: string) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "remove-arc-path" as const, animationId },
{ label: "Remove arc path", softReload: true },
);
},
- [commitMutation],
+ [commitMutationSafely],
);
const commitKeyframeAtTime = useCallback(
(
diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
index aa8db6da4..767d65061 100644
--- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts
+++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
/**
* Thin useCallback wrappers that guard on `domEditSelection` before
@@ -44,7 +45,7 @@ export function useGsapSelectionHandlers({
sel: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
time: number,
- ) => void;
+ ) => Promise;
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
updateGsapFromProperty: (
@@ -73,12 +74,28 @@ export function useGsapSelectionHandlers({
sel: DomEditSelection,
animId: string,
resolvedFromValues?: Record,
- ) => void;
+ ) => Promise;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
handleDomManualEditsReset: (sel: DomEditSelection) => void;
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
}) {
+ const trackGsapHandlerFailure = useCallback(
+ (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
+ trackStudioSaveFailure({
+ source: "gsap_commit",
+ error,
+ filePath: selection.sourceFile ?? undefined,
+ mutationType,
+ label,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ },
+ [],
+ );
+
const handleGsapUpdateProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
@@ -106,12 +123,16 @@ export function useGsapSelectionHandlers({
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
- addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime);
+ void addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime).catch(
+ (error) => {
+ trackGsapHandlerFailure(error, domEditSelection, "add", `Add GSAP ${method} animation`);
+ },
+ );
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
handleDomManualEditsReset(domEditSelection);
}
},
- [domEditSelection, addGsapAnimation, handleDomManualEditsReset],
+ [domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
);
const handleGsapAddProperty = useCallback(
@@ -165,9 +186,11 @@ export function useGsapSelectionHandlers({
const handleGsapAddKeyframeBatch = useCallback(
(animId: string, percentage: number, properties: Record) => {
if (!domEditSelection) return Promise.resolve();
- return addKeyframeBatch(domEditSelection, animId, percentage, properties);
+ return addKeyframeBatch(domEditSelection, animId, percentage, properties).catch((error) => {
+ trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
+ });
},
- [domEditSelection, addKeyframeBatch],
+ [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
);
const handleGsapRemoveKeyframe = useCallback(
(animId: string, percentage: number) => {
@@ -180,9 +203,16 @@ export function useGsapSelectionHandlers({
const handleGsapConvertToKeyframes = useCallback(
(animId: string, resolvedFromValues?: Record) => {
if (!domEditSelection) return Promise.resolve();
- return convertToKeyframes(domEditSelection, animId, resolvedFromValues);
+ return convertToKeyframes(domEditSelection, animId, resolvedFromValues).catch((error) => {
+ trackGsapHandlerFailure(
+ error,
+ domEditSelection,
+ "convert-to-keyframes",
+ "Convert to keyframes",
+ );
+ });
},
- [domEditSelection, convertToKeyframes],
+ [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
);
const handleGsapRemoveAllKeyframes = useCallback(
diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts
index 2ebe73a67..7826ff6e1 100644
--- a/packages/studio/src/hooks/usePreviewPersistence.ts
+++ b/packages/studio/src/hooks/usePreviewPersistence.ts
@@ -1,4 +1,4 @@
-import { useCallback, useRef } from "react";
+import { useCallback, useRef, useState } from "react";
import { useMountEffect } from "./useMountEffect";
import {
installStudioManualEditSeekReapply,
@@ -7,6 +7,8 @@ import {
} from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory";
+import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
+import { trackStudioEvent } from "../utils/studioTelemetry";
// ── Types ──
@@ -39,7 +41,7 @@ interface UsePreviewPersistenceParams {
export function usePreviewPersistence({
projectId,
- showToast: _showToast,
+ showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
@@ -49,16 +51,38 @@ export function usePreviewPersistence({
reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) {
- void _showToast;
void _recordEdit;
void _activeCompPathRef;
+ const [domEditSaveQueuePaused, setDomEditSaveQueuePaused] = useState(null);
+
const domTextCommitVersionRef = useRef(0);
- const domEditSaveQueueRef = useRef(Promise.resolve());
+ const showToastRef = useRef(showToast);
+ showToastRef.current = showToast;
+ const domEditSaveQueueRef = useRef | null>(null);
const applyStudioManualEditsToPreviewRef = useRef<
(iframe?: HTMLIFrameElement | null) => Promise
>(async () => {});
+ if (!domEditSaveQueueRef.current) {
+ domEditSaveQueueRef.current = createDomEditSaveQueue({
+ onOpen: (event) => {
+ const message = "Auto-save is paused. Check your connection.";
+ setDomEditSaveQueuePaused(message);
+ showToastRef.current(message, "error");
+ trackStudioEvent("save_queue_paused", {
+ source: "dom_edit",
+ error_message: event.errorMessage,
+ status_code: event.statusCode,
+ consecutive_failures: event.consecutiveFailures,
+ });
+ },
+ onReset: () => {
+ setDomEditSaveQueuePaused(null);
+ },
+ });
+ }
+
// Keep a ref to the latest projectId so async save callbacks always read the
// current value, even when the callback was captured in a stale closure.
const projectIdRef = useRef(projectId);
@@ -67,18 +91,22 @@ export function usePreviewPersistence({
// ── Queue / drain helpers ──
const queueDomEditSave = useCallback((save: () => Promise) => {
- const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
- domEditSaveQueueRef.current = queuedSave.then(
- () => undefined,
- () => undefined,
- );
- return queuedSave;
+ return domEditSaveQueueRef.current?.enqueue(save) ?? save();
}, []);
const waitForPendingDomEditSaves = useCallback(async () => {
- await domEditSaveQueueRef.current.catch(() => undefined);
+ await domEditSaveQueueRef.current?.waitForIdle();
+ }, []);
+
+ const resetDomEditSaveQueueBreaker = useCallback(() => {
+ domEditSaveQueueRef.current?.reset();
+ setDomEditSaveQueuePaused(null);
}, []);
+ useMountEffect(() => () => {
+ domEditSaveQueueRef.current?.destroy();
+ });
+
// ── Apply manual edits (HTML-baked — install seek hooks) ──
// reapplyPositionEditsAfterSeek now also handles motion reapply from DOM attributes.
@@ -192,6 +220,8 @@ export function usePreviewPersistence({
applyStudioManualEditsToPreviewRef,
queueDomEditSave,
waitForPendingDomEditSaves,
+ domEditSaveQueuePaused,
+ resetDomEditSaveQueueBreaker,
applyCurrentStudioManualEditsToPreview,
applyStudioManualEditsToPreview,
syncHistoryPreviewAfterApply,
diff --git a/packages/studio/src/hooks/useSafeGsapCommitMutation.ts b/packages/studio/src/hooks/useSafeGsapCommitMutation.ts
new file mode 100644
index 000000000..06e9a8a4c
--- /dev/null
+++ b/packages/studio/src/hooks/useSafeGsapCommitMutation.ts
@@ -0,0 +1,64 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
+
+type CommitMutationOptions = {
+ label: string;
+ coalesceKey?: string;
+ softReload?: boolean;
+ skipReload?: boolean;
+ beforeReload?: () => void;
+};
+
+type CommitMutation = (
+ selection: DomEditSelection,
+ mutation: Record,
+ options: CommitMutationOptions,
+) => Promise;
+
+type TrackGsapSaveFailure = (
+ error: unknown,
+ selection: DomEditSelection,
+ mutation: Record,
+ label?: string,
+) => void;
+
+function getGsapMutationType(mutation: Record): string {
+ return typeof mutation.type === "string" ? mutation.type : "gsap";
+}
+
+export function useGsapSaveFailureTelemetry(activeCompPath: string | null): TrackGsapSaveFailure {
+ return useCallback(
+ (error, selection, mutation, label) => {
+ trackStudioSaveFailure({
+ source: "gsap_commit",
+ error,
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
+ mutationType: getGsapMutationType(mutation),
+ label,
+ targetId: selection.id,
+ targetSelector: selection.selector,
+ targetSourceFile: selection.sourceFile,
+ });
+ },
+ [activeCompPath],
+ );
+}
+
+export function useSafeGsapCommitMutation(
+ commitMutation: CommitMutation,
+ trackGsapSaveFailure: TrackGsapSaveFailure,
+) {
+ return useCallback(
+ (
+ selection: DomEditSelection,
+ mutation: Record,
+ options: CommitMutationOptions,
+ ) => {
+ void commitMutation(selection, mutation, options).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, options.label);
+ });
+ },
+ [commitMutation, trackGsapSaveFailure],
+ );
+}
diff --git a/packages/studio/src/utils/domEditSaveQueue.test.ts b/packages/studio/src/utils/domEditSaveQueue.test.ts
new file mode 100644
index 000000000..f0e001ba9
--- /dev/null
+++ b/packages/studio/src/utils/domEditSaveQueue.test.ts
@@ -0,0 +1,112 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createDomEditSaveQueue } from "./domEditSaveQueue";
+import { StudioSaveHttpError } from "./studioSaveDiagnostics";
+
+describe("dom edit save queue", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("opens the breaker after consecutive failures and rejects new work until reset", async () => {
+ const onOpen = vi.fn();
+ const onReset = vi.fn();
+ const queue = createDomEditSaveQueue({
+ failureThreshold: 2,
+ onOpen,
+ onReset,
+ });
+
+ await expect(
+ queue.enqueue(async () => {
+ throw new StudioSaveHttpError("Server down", 503);
+ }),
+ ).rejects.toThrow("Server down");
+ await expect(
+ queue.enqueue(async () => {
+ throw new StudioSaveHttpError("Still down", 503);
+ }),
+ ).rejects.toThrow("Still down");
+
+ expect(onOpen).toHaveBeenCalledWith({
+ consecutiveFailures: 2,
+ errorMessage: "Still down",
+ statusCode: 503,
+ });
+
+ let thirdRan = false;
+ await expect(
+ queue.enqueue(async () => {
+ thirdRan = true;
+ }),
+ ).rejects.toThrow("Auto-save is paused");
+ expect(thirdRan).toBe(false);
+
+ queue.reset();
+ expect(onReset).toHaveBeenCalledOnce();
+
+ await queue.enqueue(async () => {
+ thirdRan = true;
+ });
+ expect(thirdRan).toBe(true);
+ queue.destroy();
+ });
+
+ it("resets an open breaker when already queued work succeeds", async () => {
+ const onOpen = vi.fn();
+ const onReset = vi.fn();
+ const queue = createDomEditSaveQueue({
+ failureThreshold: 1,
+ onOpen,
+ onReset,
+ });
+
+ let rejectFirst: ((error: Error) => void) | null = null;
+ let resolveFirstStarted: (() => void) | null = null;
+ const firstStarted = new Promise((resolve) => {
+ resolveFirstStarted = resolve;
+ });
+ const first = queue.enqueue(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ resolveFirstStarted?.();
+ }),
+ );
+ const second = queue.enqueue(async () => {});
+
+ await firstStarted;
+ expect(rejectFirst).toBeTypeOf("function");
+ rejectFirst?.(new StudioSaveHttpError("Server down", 503));
+ await expect(first).rejects.toThrow("Server down");
+ await expect(second).resolves.toBeUndefined();
+
+ expect(onOpen).toHaveBeenCalledOnce();
+ expect(onReset).toHaveBeenCalledOnce();
+ queue.destroy();
+ });
+
+ it("resets consecutive failures after a successful save", async () => {
+ const onOpen = vi.fn();
+ const queue = createDomEditSaveQueue({
+ failureThreshold: 2,
+ onOpen,
+ });
+
+ await expect(
+ queue.enqueue(async () => {
+ throw new Error("first failure");
+ }),
+ ).rejects.toThrow("first failure");
+
+ await queue.enqueue(async () => {});
+
+ await expect(
+ queue.enqueue(async () => {
+ throw new Error("second failure after success");
+ }),
+ ).rejects.toThrow("second failure after success");
+
+ expect(onOpen).not.toHaveBeenCalled();
+ queue.destroy();
+ });
+});
diff --git a/packages/studio/src/utils/domEditSaveQueue.ts b/packages/studio/src/utils/domEditSaveQueue.ts
new file mode 100644
index 000000000..161ad3d96
--- /dev/null
+++ b/packages/studio/src/utils/domEditSaveQueue.ts
@@ -0,0 +1,87 @@
+import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
+
+interface DomEditSaveQueueOpenEvent {
+ consecutiveFailures: number;
+ errorMessage: string;
+ statusCode: number | null;
+}
+
+interface DomEditSaveQueueOptions {
+ failureThreshold?: number;
+ onOpen?: (event: DomEditSaveQueueOpenEvent) => void;
+ onReset?: () => void;
+}
+
+export interface DomEditSaveQueue {
+ enqueue: (save: () => Promise) => Promise;
+ waitForIdle: () => Promise;
+ reset: () => void;
+ destroy: () => void;
+}
+
+const DEFAULT_FAILURE_THRESHOLD = 5;
+
+export class DomEditSaveQueueOpenError extends Error {
+ constructor() {
+ super("Auto-save is paused. Dismiss the warning to retry DOM edits.");
+ this.name = "DomEditSaveQueueOpenError";
+ }
+}
+
+export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): DomEditSaveQueue {
+ const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
+
+ let tail = Promise.resolve();
+ let consecutiveFailures = 0;
+ let breakerOpen = false;
+
+ const reset = (notify = true) => {
+ const wasOpen = breakerOpen;
+ consecutiveFailures = 0;
+ breakerOpen = false;
+ if (notify && wasOpen) options.onReset?.();
+ };
+
+ const open = (error: unknown) => {
+ if (breakerOpen) return;
+ breakerOpen = true;
+ options.onOpen?.({
+ consecutiveFailures,
+ errorMessage: getStudioSaveErrorMessage(error),
+ statusCode: getStudioSaveStatusCode(error) ?? null,
+ });
+ };
+
+ const run = async (save: () => Promise) => {
+ try {
+ await save();
+ reset();
+ } catch (error) {
+ consecutiveFailures += 1;
+ if (consecutiveFailures >= failureThreshold) open(error);
+ throw error;
+ }
+ };
+
+ return {
+ enqueue(save) {
+ if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
+ const queued = tail.catch(() => undefined).then(() => run(save));
+ tail = queued.then(
+ () => undefined,
+ () => undefined,
+ );
+ return queued;
+ },
+
+ async waitForIdle() {
+ await tail.catch(() => undefined);
+ },
+
+ reset,
+
+ destroy() {
+ reset(false);
+ },
+ };
+}
diff --git a/packages/studio/src/utils/studioSaveDiagnostics.test.ts b/packages/studio/src/utils/studioSaveDiagnostics.test.ts
new file mode 100644
index 000000000..b004cf739
--- /dev/null
+++ b/packages/studio/src/utils/studioSaveDiagnostics.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ StudioSaveHttpError,
+ buildStudioSaveFailureProperties,
+ getStudioSaveStatusCode,
+ retryStudioSave,
+} from "./studioSaveDiagnostics";
+
+describe("studio save diagnostics", () => {
+ it("builds save_failure properties with stable diagnostics", () => {
+ const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
+
+ expect(
+ buildStudioSaveFailureProperties({
+ source: "code_editor",
+ error,
+ filePath: "index.html",
+ mutationType: "put",
+ attempt: 3,
+ }),
+ ).toEqual({
+ source: "code_editor",
+ error_message: "Failed to save index.html (503)",
+ status_code: 503,
+ file_path: "index.html",
+ mutation_type: "put",
+ attempt: 3,
+ label: undefined,
+ target_id: undefined,
+ target_selector: undefined,
+ target_source_file: undefined,
+ });
+ });
+
+ it("reads nested status codes from error causes", () => {
+ const cause = new StudioSaveHttpError("Too many requests", 429);
+ const error = new Error("retry wrapper") as Error & { cause?: unknown };
+ error.cause = cause;
+
+ expect(getStudioSaveStatusCode(error)).toBe(429);
+ });
+
+ it("retries transient save failures with exponential backoff and jitter", async () => {
+ const sleeps: number[] = [];
+ const operation = vi
+ .fn<(attempt: number) => Promise>()
+ .mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503))
+ .mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503))
+ .mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503))
+ .mockResolvedValue("saved");
+
+ await expect(
+ retryStudioSave(operation, {
+ random: () => 0.5,
+ sleep: async (delayMs) => {
+ sleeps.push(delayMs);
+ },
+ }),
+ ).resolves.toBe("saved");
+
+ expect(operation).toHaveBeenCalledTimes(4);
+ expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]);
+ expect(sleeps).toEqual([500, 1000, 2000]);
+ });
+
+ it("does not retry non-transient client failures", async () => {
+ const operation = vi
+ .fn<(attempt: number) => Promise>()
+ .mockRejectedValue(new StudioSaveHttpError("Too large", 413));
+
+ await expect(
+ retryStudioSave(operation, {
+ sleep: async () => {},
+ }),
+ ).rejects.toThrow("Too large");
+
+ expect(operation).toHaveBeenCalledTimes(1);
+ });
+
+ it("aborts while waiting between retry attempts", async () => {
+ const controller = new AbortController();
+ const operation = vi
+ .fn<(attempt: number) => Promise>()
+ .mockRejectedValue(new StudioSaveHttpError("Server restarting", 503));
+
+ const pending = retryStudioSave(operation, {
+ signal: controller.signal,
+ sleep: async (_delayMs, signal) => {
+ controller.abort();
+ if (signal?.aborted) throw new DOMException("Save aborted", "AbortError");
+ },
+ });
+
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ expect(operation).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/studio/src/utils/studioSaveDiagnostics.ts b/packages/studio/src/utils/studioSaveDiagnostics.ts
new file mode 100644
index 000000000..c37d5244b
--- /dev/null
+++ b/packages/studio/src/utils/studioSaveDiagnostics.ts
@@ -0,0 +1,192 @@
+import { trackStudioEvent } from "./studioTelemetry";
+
+type StudioTelemetryValue = string | number | boolean | null | undefined;
+const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
+
+export interface StudioSaveFailureInput {
+ source: string;
+ error: unknown;
+ statusCode?: number | null;
+ filePath?: string | null;
+ mutationType?: string | null;
+ attempt?: number | null;
+ label?: string | null;
+ targetId?: string | null;
+ targetSelector?: string | null;
+ targetSourceFile?: string | null;
+}
+
+export class StudioSaveHttpError extends Error {
+ readonly statusCode: number;
+
+ constructor(message: string, statusCode: number) {
+ super(message);
+ this.name = "StudioSaveHttpError";
+ this.statusCode = statusCode;
+ }
+}
+
+function readNumericProperty(value: object, key: string): number | undefined {
+ const record = value as Record;
+ const property = record[key];
+ return typeof property === "number" && Number.isFinite(property) ? property : undefined;
+}
+
+function createStudioSaveAbortError(): Error {
+ if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
+ const error = new Error("Save aborted");
+ error.name = "AbortError";
+ return error;
+}
+
+function throwIfStudioSaveAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) throw createStudioSaveAbortError();
+}
+
+function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
+ if (!error || typeof error !== "object") return error;
+ try {
+ Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
+ value: attempt,
+ configurable: true,
+ });
+ } catch {
+ // Best-effort diagnostic only.
+ }
+ return error;
+}
+
+export function getStudioSaveErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string" && error.trim()) return error;
+ return "Unknown save failure";
+}
+
+export function getStudioSaveStatusCode(error: unknown): number | undefined {
+ if (!error || typeof error !== "object") return undefined;
+ const direct =
+ readNumericProperty(error, "statusCode") ??
+ readNumericProperty(error, "status") ??
+ readNumericProperty(error, "status_code");
+ if (direct != null) return direct;
+
+ const cause = (error as { cause?: unknown }).cause;
+ if (cause && cause !== error) return getStudioSaveStatusCode(cause);
+ return undefined;
+}
+
+export function getStudioSaveAttempt(error: unknown): number | undefined {
+ if (!error || typeof error !== "object") return undefined;
+ const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
+ if (direct != null) return direct;
+
+ const cause = (error as { cause?: unknown }).cause;
+ if (cause && cause !== error) return getStudioSaveAttempt(cause);
+ return undefined;
+}
+
+export function isStudioSaveAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+}
+
+function isRetryableStudioSaveError(error: unknown): boolean {
+ if (isStudioSaveAbortError(error)) return false;
+ const statusCode = getStudioSaveStatusCode(error);
+ if (statusCode == null) return true;
+ return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
+}
+
+export function buildStudioSaveFailureProperties(
+ input: StudioSaveFailureInput,
+): Record {
+ const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
+ const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
+ return {
+ source: input.source,
+ error_message: getStudioSaveErrorMessage(input.error),
+ status_code: statusCode,
+ file_path: input.filePath ?? input.targetSourceFile ?? undefined,
+ mutation_type: input.mutationType ?? undefined,
+ attempt,
+ label: input.label ?? undefined,
+ target_id: input.targetId ?? undefined,
+ target_selector: input.targetSelector ?? undefined,
+ target_source_file: input.targetSourceFile ?? undefined,
+ };
+}
+
+export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
+ trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
+}
+
+export async function createStudioSaveHttpError(
+ response: Response,
+ fallbackMessage: string,
+): Promise {
+ let body = "";
+ try {
+ body = await response.text();
+ } catch {
+ body = "";
+ }
+ const detail = body.trim().slice(0, 300);
+ const message = detail
+ ? `${fallbackMessage} (${response.status}): ${detail}`
+ : `${fallbackMessage} (${response.status})`;
+ return new StudioSaveHttpError(message, response.status);
+}
+
+export async function retryStudioSave(
+ operation: (attempt: number) => Promise,
+ options: {
+ retries?: number;
+ baseDelayMs?: number;
+ maxDelayMs?: number;
+ jitterRatio?: number;
+ random?: () => number;
+ signal?: AbortSignal;
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
+ sleep?: (delayMs: number, signal?: AbortSignal) => Promise;
+ } = {},
+): Promise {
+ const retries = options.retries ?? 3;
+ const baseDelayMs = options.baseDelayMs ?? 500;
+ const maxDelayMs = options.maxDelayMs ?? 8000;
+ const jitterRatio = options.jitterRatio ?? 0.25;
+ const random = options.random ?? Math.random;
+ const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
+ const sleep =
+ options.sleep ??
+ ((delayMs: number, signal?: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ throwIfStudioSaveAborted(signal);
+ const onAbort = () => {
+ globalThis.clearTimeout(timeout);
+ reject(createStudioSaveAbortError());
+ };
+ const timeout = globalThis.setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, delayMs);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ }));
+ const maxAttempts = retries + 1;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ throwIfStudioSaveAborted(options.signal);
+ return await operation(attempt);
+ } catch (error) {
+ const failure = attachStudioSaveAttempt(error, attempt);
+ if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
+ const retryIndex = attempt - 1;
+ const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
+ const jitterSpan = exponentialDelay * jitterRatio;
+ const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
+ const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
+ await sleep(delayMs, options.signal);
+ }
+ }
+
+ throw new Error("Save retry loop exited unexpectedly");
+}