mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user