diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/core/src/studio-api/helpers/safePath.test.ts
index cf4da1b05..f3b8ff47e 100644
--- a/packages/core/src/studio-api/helpers/safePath.test.ts
+++ b/packages/core/src/studio-api/helpers/safePath.test.ts
@@ -22,10 +22,15 @@ describe("walkDir", () => {
it("hides internal HyperFrames backup files from project listings", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
+ mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
+ writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
- expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
+ expect(walkDir(projectDir)).toEqual([
+ ".hyperframes/examples/preset.html",
+ "compositions/scene.html",
+ ]);
});
});
diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts
index 5a2e78121..25edab21f 100644
--- a/packages/core/src/studio-api/helpers/safePath.ts
+++ b/packages/core/src/studio-api/helpers/safePath.ts
@@ -7,7 +7,11 @@ export function isSafePath(base: string, resolved: string): boolean {
return resolved.startsWith(norm) || resolved === resolve(base);
}
-const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
+const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
+
+function shouldIgnoreDir(rel: string): boolean {
+ return rel === ".hyperframes/backup";
+}
/**
* True when any directory segment of a relative path is a dot-directory or
@@ -25,8 +29,8 @@ export function isInHiddenOrVendorDir(relPath: string): boolean {
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
- if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index f100423b2..c5fff0402 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";
@@ -478,6 +479,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/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts
new file mode 100644
index 000000000..ed971c90b
--- /dev/null
+++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts
@@ -0,0 +1,33 @@
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+
+export const PROPERTY_DEFAULTS: Record = {
+ opacity: 1,
+ x: 0,
+ y: 0,
+ scale: 1,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 0,
+ width: 100,
+ height: 100,
+};
+
+export function ensureElementAddressable(selection: DomEditSelection): {
+ selector: string;
+ autoId?: string;
+} {
+ if (selection.id) return { selector: `#${selection.id}` };
+ if (selection.selector) return { selector: selection.selector };
+
+ const el = selection.element;
+ const doc = el.ownerDocument;
+ const tag = el.tagName.toLowerCase();
+ let id = tag;
+ let n = 1;
+ while (doc.getElementById(id)) {
+ n += 1;
+ id = `${tag}-${n}`;
+ }
+ el.setAttribute("id", id);
+ return { selector: `#${id}`, autoId: id };
+}
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index ac9e23353..182aa8635 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,
@@ -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;
reloadPreview: () => void;
+ // From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
@@ -115,6 +121,8 @@ export interface UseDomEditCommitsParams {
) => Promise;
}
+// ── 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 =
diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
new file mode 100644
index 000000000..c49bc89f2
--- /dev/null
+++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
@@ -0,0 +1,53 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditing";
+import type { PatchOperation } from "../utils/sourcePatcher";
+import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
+import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
+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) => {
+ return queueDomEditSave(async () => {
+ await persistDomEditOperations(selection, patches, {
+ label: options.label,
+ coalesceKey: options.coalesceKey,
+ skipRefresh: options.skipRefresh ?? true,
+ });
+ }).catch((error) => {
+ if (error instanceof DomEditSaveQueueOpenError) return;
+ 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,
+ });
+ throw error;
+ });
+ },
+ [activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
+ );
+}
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 7299aaeb7..e11ebd495 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,6 +326,10 @@ 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 }) => {
@@ -335,22 +339,20 @@ export function useDomEditSession({
throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
}
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
- 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;
+ }
}
return handleDomPathOffsetCommit(selection, next);
},
@@ -359,38 +361,29 @@ export function useDomEditSession({
selectedGsapAnimations,
gsapCommitMutation,
previewIframeRef,
- projectId,
- gsapSourceFile,
+ makeFetchFallback,
+ trackGsapInteractionFailure,
showToast,
],
);
- 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 (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
- 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;
+ }
}
return handleDomBoxSizeCommit(selection, next);
},
@@ -400,21 +393,27 @@ export function useDomEditSession({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
+ trackGsapInteractionFailure,
],
);
const handleGsapAwareRotationCommit = useCallback(
async (selection: DomEditSelection, next: { angle: number }) => {
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
- 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;
+ }
}
return handleDomRotationCommit(selection, next);
},
@@ -424,6 +423,7 @@ export function useDomEditSession({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
+ trackGsapInteractionFailure,
],
);
diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts
index f977d8bd9..6309b42dd 100644
--- a/packages/studio/src/hooks/useFileManager.ts
+++ b/packages/studio/src/hooks/useFileManager.ts
@@ -6,6 +6,11 @@ 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,
+ retryStudioSave,
+ StudioSaveNetworkError,
+} from "../utils/studioSaveDiagnostics";
// ── Types ──
@@ -97,12 +102,21 @@ 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,
+ await retryStudioSave(async () => {
+ let response: Response;
+ try {
+ response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "text/plain" },
+ body: content,
+ });
+ } catch (error) {
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
+ cause: error,
+ });
+ }
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
});
- if (!response.ok) throw new Error(`Failed to save ${path}`);
if (editingPathRef.current === path) {
setEditingFile({ path, content });
}
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 c0c5b7e2a..d9261b97e 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -11,43 +11,12 @@ import {
readKeyframeSnapshot,
writeKeyframeCache,
} from "./gsapKeyframeCacheHelpers";
-
-const PROPERTY_DEFAULTS: Record = {
- opacity: 1,
- x: 0,
- y: 0,
- scale: 1,
- scaleX: 1,
- scaleY: 1,
- rotation: 0,
- width: 100,
- height: 100,
-};
-
-/**
- * Ensures the element has an id so it can be targeted by a GSAP selector.
- * If the element already has an id or a CSS selector, returns those.
- * Otherwise mints a unique id and sets it on the live element.
- */
-function ensureElementAddressable(selection: DomEditSelection): {
- selector: string;
- autoId?: string;
-} {
- if (selection.id) return { selector: `#${selection.id}` };
- if (selection.selector) return { selector: selection.selector };
-
- const el = selection.element;
- const doc = el.ownerDocument;
- const tag = el.tagName.toLowerCase();
- let id = tag;
- let n = 1;
- while (doc.getElementById(id)) {
- n += 1;
- id = `${tag}-${n}`;
- }
- el.setAttribute("id", id);
- return { selector: `#${id}`, autoId: id };
-}
+import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
+import {
+ useGsapSaveFailureTelemetry,
+ useSafeGsapCommitMutation,
+} from "./useSafeGsapCommitMutation";
+import { ensureElementAddressable, PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
interface MutationResult {
ok: boolean;
@@ -56,29 +25,50 @@ interface MutationResult {
before?: string;
after?: string;
scriptText?: string;
- path?: string;
}
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;
+}
+
+function executeOptimisticKeyframeCacheUpdate(options: {
+ sourceFile: string;
+ elementId: string | null | undefined;
+ apply: (entry: KeyframeCacheEntry) => KeyframeCacheEntry;
+ persist: () => Promise;
+}): Promise {
+ return executeOptimistic({
+ apply: () => {
+ const prev = readKeyframeSnapshot(options.sourceFile, options.elementId);
+ if (prev) writeKeyframeCache(options.sourceFile, options.elementId, options.apply(prev));
+ return prev;
+ },
+ persist: options.persist,
+ rollback: (prev) => {
+ writeKeyframeCache(options.sourceFile, options.elementId, prev);
+ },
+ });
}
+
interface GsapScriptCommitsParams {
projectIdRef: React.MutableRefObject;
activeCompPath: string | null;
@@ -95,11 +85,11 @@ interface GsapScriptCommitsParams {
reloadPreview: () => void;
onCacheInvalidate: () => void;
onFileContentChanged?: (path: string, content: string) => void;
- showToast?: (message: string, tone?: "error" | "info") => void;
+ showToast: (message: string, tone?: "error" | "info") => void;
}
const DEBOUNCE_MS = 150;
-// fallow-ignore-next-line complexity unit-size
+// fallow-ignore-next-line complexity
export function useGsapScriptCommits({
projectIdRef,
activeCompPath,
@@ -136,10 +126,6 @@ export function useGsapScriptCommits({
if (!pid) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const result = await mutateGsapScript(pid, targetPath, mutation);
- if (!result) {
- if (options.skipReload) return;
- throw new Error(`Mutation failed: ${mutation.type}`);
- }
if (result.changed === false) {
if (options.skipReload) return;
@@ -160,7 +146,6 @@ export function useGsapScriptCommits({
if (result.after != null) {
onFileContentChanged?.(targetPath, result.after);
}
- showToast?.(`Updated ${result.path ?? targetPath}`, "info");
if (options.skipReload) return;
@@ -199,15 +184,22 @@ export function useGsapScriptCommits({
reloadPreview,
onCacheInvalidate,
onFileContentChanged,
- showToast,
],
);
+
+ const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
+ const commitMutationSafely = useSafeGsapCommitMutation(
+ commitMutation,
+ trackGsapSaveFailure,
+ showToast,
+ );
+
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 },
{
@@ -216,7 +208,7 @@ export function useGsapScriptCommits({
softReload: true,
},
);
- }, [commitMutation]);
+ }, [commitMutationSafely]);
const updateGsapProperty = useCallback(
(
@@ -244,7 +236,7 @@ export function useGsapScriptCommits({
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{
@@ -253,17 +245,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 deleteAllForSelector = useCallback(
(selection: DomEditSelection, targetSelector: string) => {
@@ -304,9 +296,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;
@@ -349,23 +348,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(
(
@@ -374,7 +373,7 @@ export function useGsapScriptCommits({
property: string,
value: number | string,
) => {
- void commitMutation(
+ commitMutationSafely(
selection,
{ type: "update-from-property", animationId, property, value },
{
@@ -383,28 +382,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(
(
@@ -416,30 +415,31 @@ export function useGsapScriptCommits({
) => {
const sf = selection.sourceFile || activeCompPath || "index.html";
const elementId = selection.id;
- void executeOptimistic({
- apply: () => {
- const prev = readKeyframeSnapshot(sf, elementId);
- if (prev) {
- const newKeyframes = [
- ...prev.keyframes,
- { percentage, properties: { [property]: value } },
- ].sort((a, b) => a.percentage - b.percentage);
- writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
- }
- return prev;
- },
- persist: () =>
- commitMutation(
- selection,
- { type: "add-keyframe", animationId, percentage, properties: { [property]: value } },
- { label: `Add keyframe at ${percentage}%`, softReload: true },
+ const mutation = {
+ type: "add-keyframe",
+ animationId,
+ percentage,
+ properties: { [property]: value },
+ };
+ void executeOptimisticKeyframeCacheUpdate({
+ sourceFile: sf,
+ elementId,
+ apply: (prev) => ({
+ ...prev,
+ keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
+ (a, b) => a.percentage - b.percentage,
),
- rollback: (prev) => {
- writeKeyframeCache(sf, elementId, prev);
- },
+ }),
+ persist: () =>
+ commitMutation(selection, mutation, {
+ label: `Add keyframe at ${percentage}%`,
+ softReload: true,
+ }),
+ }).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
});
},
- [commitMutation, activeCompPath],
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
);
const addKeyframeBatch = useCallback(
(
@@ -460,29 +460,26 @@ export function useGsapScriptCommits({
(selection: DomEditSelection, animationId: string, percentage: number) => {
const sf = selection.sourceFile || activeCompPath || "index.html";
const elementId = selection.id;
- void executeOptimistic({
- apply: () => {
- const prev = readKeyframeSnapshot(sf, elementId);
- if (prev) {
- const newKeyframes = prev.keyframes.filter(
- (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
- );
- writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
- }
- return prev;
- },
- persist: () =>
- commitMutation(
- selection,
- { type: "remove-keyframe", animationId, percentage },
- { label: `Remove keyframe at ${percentage}%`, softReload: true },
+ const mutation = { type: "remove-keyframe", animationId, percentage };
+ void executeOptimisticKeyframeCacheUpdate({
+ sourceFile: sf,
+ elementId,
+ apply: (prev) => ({
+ ...prev,
+ keyframes: prev.keyframes.filter(
+ (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
),
- rollback: (prev) => {
- writeKeyframeCache(sf, elementId, prev);
- },
+ }),
+ persist: () =>
+ commitMutation(selection, mutation, {
+ label: `Remove keyframe at ${percentage}%`,
+ softReload: true,
+ }),
+ }).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
});
},
- [commitMutation, activeCompPath],
+ [commitMutation, activeCompPath, trackGsapSaveFailure],
);
const convertToKeyframes = useCallback(
(
@@ -500,13 +497,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(
(
@@ -522,13 +519,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(
(
@@ -541,23 +538,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 2eae4b4f3..a25abb3b9 100644
--- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts
+++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
@@ -1,6 +1,7 @@
import { useCallback, useRef } 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
@@ -46,7 +47,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: (
@@ -75,7 +76,7 @@ export function useGsapSelectionHandlers({
sel: DomEditSelection,
animId: string,
resolvedFromValues?: Record,
- ) => void;
+ ) => Promise;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
handleDomManualEditsReset: (sel: DomEditSelection) => void;
@@ -84,6 +85,22 @@ export function useGsapSelectionHandlers({
const lastSelectionRef = useRef(null);
if (domEditSelection) lastSelectionRef.current = domEditSelection;
+ 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;
@@ -121,12 +138,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(
@@ -180,9 +201,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) => {
@@ -195,9 +218,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..d498169cb 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 ──
@@ -35,11 +37,51 @@ interface UsePreviewPersistenceParams {
reloadPreview: () => void;
}
+function readIframeDocument(iframe: HTMLIFrameElement): Document | null {
+ try {
+ return iframe.contentDocument;
+ } catch {
+ return null;
+ }
+}
+
+function installManualEditReapply(iframe: HTMLIFrameElement): void {
+ const reapply = () => {
+ const doc = readIframeDocument(iframe);
+ if (doc) reapplyPositionEditsAfterSeek(doc);
+ };
+ const install = () => {
+ reapply();
+ if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
+ };
+ const win = iframe.contentWindow;
+ install();
+ win?.requestAnimationFrame?.(install);
+ for (const delayMs of [80, 250, 500, 1000, 2000]) {
+ win?.setTimeout?.(install, delayMs);
+ }
+}
+
+function shouldReloadForStudioFileChange(
+ payload: unknown,
+ pendingTimelineEditPathRef: React.MutableRefObject> | undefined,
+ domEditSaveTimestampRef: React.MutableRefObject,
+): boolean {
+ const changedPath = readStudioFileChangePath(payload);
+ if (!changedPath) return false;
+ const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
+ if (pendingTimelinePaths?.has(changedPath)) {
+ pendingTimelinePaths.delete(changedPath);
+ return false;
+ }
+ return Date.now() - domEditSaveTimestampRef.current >= 4000;
+}
+
// ── Hook ──
export function usePreviewPersistence({
projectId,
- showToast: _showToast,
+ showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
@@ -49,16 +91,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,55 +131,30 @@ 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.
const applyCurrentStudioManualEditsToPreview = useCallback(
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
if (!iframe) return;
- let doc: Document | null = null;
- try {
- doc = iframe.contentDocument;
- } catch {
- return;
- }
- if (!doc) return;
-
- const reapply = () => {
- let d: Document | null = null;
- try {
- d = iframe.contentDocument;
- } catch {
- return;
- }
- if (d) reapplyPositionEditsAfterSeek(d);
- };
-
- const install = () => {
- reapply();
- if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
- };
-
- const win = iframe.contentWindow;
- install();
- win?.requestAnimationFrame?.(install);
- win?.setTimeout?.(install, 80);
- win?.setTimeout?.(install, 250);
- win?.setTimeout?.(install, 500);
- win?.setTimeout?.(install, 1000);
- win?.setTimeout?.(install, 2000);
+ if (!readIframeDocument(iframe)) return;
+ installManualEditReapply(iframe);
},
[previewIframeRef],
);
@@ -165,16 +204,14 @@ export function usePreviewPersistence({
// ── Listen for external file changes (HMR / SSE) ──
useMountEffect(() => {
const handler = (payload?: unknown) => {
- const changedPath = readStudioFileChangePath(payload);
- if (!changedPath) return;
- const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
- if (pendingTimelineEditPathRef?.current.has(changedPath)) {
- pendingTimelineEditPathRef.current.delete(changedPath);
- return;
- }
- if (!recentDomEditSave) {
+ if (
+ shouldReloadForStudioFileChange(
+ payload,
+ pendingTimelineEditPathRef,
+ domEditSaveTimestampRef,
+ )
+ )
reloadPreview();
- }
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
@@ -192,6 +229,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..92130c54d
--- /dev/null
+++ b/packages/studio/src/hooks/useSafeGsapCommitMutation.ts
@@ -0,0 +1,66 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+import { getStudioSaveErrorMessage, 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,
+ showToast?: (message: string, tone?: "error" | "info") => void,
+) {
+ return useCallback(
+ (
+ selection: DomEditSelection,
+ mutation: Record,
+ options: CommitMutationOptions,
+ ) => {
+ void commitMutation(selection, mutation, options).catch((error) => {
+ trackGsapSaveFailure(error, selection, mutation, options.label);
+ showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
+ });
+ },
+ [commitMutation, trackGsapSaveFailure, showToast],
+ );
+}
diff --git a/packages/studio/src/utils/domEditSaveQueue.test.ts b/packages/studio/src/utils/domEditSaveQueue.test.ts
new file mode 100644
index 000000000..ddc9f9dde
--- /dev/null
+++ b/packages/studio/src/utils/domEditSaveQueue.test.ts
@@ -0,0 +1,117 @@
+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("keeps an open breaker paused even 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).not.toHaveBeenCalled();
+
+ await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
+
+ queue.reset();
+ 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..a3024892e
--- /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();
+ if (!breakerOpen) consecutiveFailures = 0;
+ } 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..07172e22b
--- /dev/null
+++ b/packages/studio/src/utils/studioSaveDiagnostics.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ StudioSaveHttpError,
+ StudioSaveNetworkError,
+ 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("retries typed network failures", async () => {
+ const operation = vi
+ .fn<(attempt: number) => Promise>()
+ .mockRejectedValueOnce(new StudioSaveNetworkError("network dropped"))
+ .mockResolvedValue("saved");
+
+ await expect(
+ retryStudioSave(operation, {
+ sleep: async () => {},
+ }),
+ ).resolves.toBe("saved");
+
+ expect(operation).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not retry plain JavaScript errors", async () => {
+ const operation = vi
+ .fn<(attempt: number) => Promise>()
+ .mockRejectedValue(new Error("local assertion failed"));
+
+ await expect(
+ retryStudioSave(operation, {
+ sleep: async () => {},
+ }),
+ ).rejects.toThrow("local assertion failed");
+
+ 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..8f4f34367
--- /dev/null
+++ b/packages/studio/src/utils/studioSaveDiagnostics.ts
@@ -0,0 +1,200 @@
+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;
+ }
+}
+
+export class StudioSaveNetworkError extends Error {
+ constructor(message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "StudioSaveNetworkError";
+ }
+}
+
+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;
+}
+
+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;
+}
+
+function isStudioSaveAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+}
+
+function isRetryableStudioSaveError(error: unknown): boolean {
+ if (isStudioSaveAbortError(error)) return false;
+ if (error instanceof StudioSaveNetworkError) return true;
+ const statusCode = getStudioSaveStatusCode(error);
+ if (statusCode == null) return false;
+ 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");
+}