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
@@ -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,