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

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

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

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

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

* fix(studio): narrow save retry changes for fallow
This commit is contained in:
Miguel Ángel
2026-06-12 22:02:17 -04:00
committed by GitHub
parent 84a56986c6
commit 5f12e692d5
19 changed files with 1122 additions and 285 deletions
@@ -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",
]);
});
});
@@ -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 {
+8
View File
@@ -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 && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}
<div className="flex flex-1 min-h-0">
<StudioLeftSidebar
leftSidebarRef={leftSidebarRef}
@@ -0,0 +1,23 @@
interface SaveQueuePausedBannerProps {
message: string;
onDismiss: () => void;
}
/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
return (
<div
className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
role="alert"
>
<span>{message}</span>
<button
type="button"
onClick={onDismiss}
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
>
Dismiss
</button>
</div>
);
}
@@ -0,0 +1,33 @@
import type { DomEditSelection } from "../components/editor/domEditingTypes";
export const PROPERTY_DEFAULTS: Record<string, number> = {
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 };
}
+29 -39
View File
@@ -6,6 +6,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import {
buildDomEditPatchTarget,
getDomEditTargetKey,
@@ -31,8 +32,10 @@ import {
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Helpers ──
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
@@ -69,6 +72,8 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
return false;
}
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
@@ -102,6 +107,7 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
@@ -115,6 +121,8 @@ export interface UseDomEditCommitsParams {
) => Promise<DomEditSelection | null>;
}
// ── Hook ──
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
@@ -172,7 +180,9 @@ export function useDomEditCommits({
const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
if (!readResponse.ok) {
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
}
const readData = (await readResponse.json()) as { content?: string };
const originalContent = readData.content;
if (typeof originalContent !== "string") {
@@ -196,14 +206,15 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget, operations }),
},
);
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
if (!patchResponse.ok) {
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
}
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
matched?: boolean;
content?: string;
path?: string;
};
if (!patchData.changed) {
@@ -243,7 +254,6 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
showToast(`Updated ${patchData.path ?? targetPath}`, "info");
if (!options?.skipRefresh) {
reloadPreview();
@@ -256,7 +266,6 @@ export function useDomEditCommits({
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
],
);
@@ -282,38 +291,12 @@ export function useDomEditCommits({
resolveImportedFontAsset,
});
// ── Position patch helper ──
// fallow-ignore-next-line complexity
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => {
return queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
// fallow-ignore-next-line complexity
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
trackStudioEvent("save_failure", {
source: "dom_edit",
label: options.label,
error_message: message,
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
});
throw error;
});
},
[persistDomEditOperations, queueDomEditSave, showToast],
);
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
});
// ── Position commits ──
@@ -426,7 +409,9 @@ export function useDomEditCommits({
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
if (!response.ok) {
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
@@ -447,7 +432,12 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
if (!removeResponse.ok) {
throw await createStudioSaveHttpError(
removeResponse,
`Failed to delete element from ${targetPath}`,
);
}
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
const patchedContent =
@@ -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<void>) => Promise<void>;
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],
);
}
+52 -52
View File
@@ -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,
],
);
+19 -5
View File
@@ -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<void> => {
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 });
}
@@ -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],
);
}
@@ -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],
);
}
+124 -127
View File
@@ -11,43 +11,12 @@ import {
readKeyframeSnapshot,
writeKeyframeCache,
} from "./gsapKeyframeCacheHelpers";
const PROPERTY_DEFAULTS: Record<string, number> = {
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<string, unknown>,
): Promise<MutationResult | null> {
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<MutationResult> {
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<void>;
}): Promise<void> {
return executeOptimistic<KeyframeCacheEntry | undefined>({
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<string | null>;
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<KeyframeCacheEntry | undefined>({
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<KeyframeCacheEntry | undefined>({
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(
(
@@ -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<void>;
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<string, number | string>,
) => void;
) => Promise<void>;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
handleDomManualEditsReset: (sel: DomEditSelection) => void;
@@ -84,6 +85,22 @@ export function useGsapSelectionHandlers({
const lastSelectionRef = useRef<DomEditSelection | null>(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<string, number | string>) => {
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<string, number | string>) => {
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(
@@ -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<Set<string>> | undefined,
domEditSaveTimestampRef: React.MutableRefObject<number>,
): 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<string | null>(null);
const domTextCommitVersionRef = useRef(0);
const domEditSaveQueueRef = useRef(Promise.resolve());
const showToastRef = useRef(showToast);
showToastRef.current = showToast;
const domEditSaveQueueRef = useRef<ReturnType<typeof createDomEditSaveQueue> | null>(null);
const applyStudioManualEditsToPreviewRef = useRef<
(iframe?: HTMLIFrameElement | null) => Promise<void>
>(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<void>) => {
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,
@@ -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<string, unknown>,
options: CommitMutationOptions,
) => Promise<void>;
type TrackGsapSaveFailure = (
error: unknown,
selection: DomEditSelection,
mutation: Record<string, unknown>,
label?: string,
) => void;
function getGsapMutationType(mutation: Record<string, unknown>): 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<string, unknown>,
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],
);
}
@@ -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<void>((resolve) => {
resolveFirstStarted = resolve;
});
const first = queue.enqueue(
() =>
new Promise<void>((_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();
});
});
@@ -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<void>) => Promise<void>;
waitForIdle: () => Promise<void>;
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<void>) => {
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);
},
};
}
@@ -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<string>>()
.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<string>>()
.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<string>>()
.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<string>>()
.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<string>>()
.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);
});
});
@@ -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<string, unknown>;
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<string, StudioTelemetryValue> {
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<StudioSaveHttpError> {
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<T>(
operation: (attempt: number) => Promise<T>,
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<void>;
} = {},
): Promise<T> {
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<void>((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");
}