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