Skip to content

Commit 5f12e69

Browse files
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
1 parent 84a5698 commit 5f12e69

19 files changed

Lines changed: 1122 additions & 285 deletions

packages/core/src/studio-api/helpers/safePath.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ describe("walkDir", () => {
2222
it("hides internal HyperFrames backup files from project listings", () => {
2323
const projectDir = createProjectDir();
2424
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
25+
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
2526
mkdirSync(join(projectDir, "compositions"), { recursive: true });
2627
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
28+
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
2729
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
2830

29-
expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
31+
expect(walkDir(projectDir)).toEqual([
32+
".hyperframes/examples/preset.html",
33+
"compositions/scene.html",
34+
]);
3035
});
3136
});

packages/core/src/studio-api/helpers/safePath.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export function isSafePath(base: string, resolved: string): boolean {
77
return resolved.startsWith(norm) || resolved === resolve(base);
88
}
99

10-
const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
10+
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
11+
12+
function shouldIgnoreDir(rel: string): boolean {
13+
return rel === ".hyperframes/backup";
14+
}
1115

1216
/**
1317
* True when any directory segment of a relative path is a dot-directory or
@@ -25,8 +29,8 @@ export function isInHiddenOrVendorDir(relPath: string): boolean {
2529
export function walkDir(dir: string, prefix = ""): string[] {
2630
const files: string[] = [];
2731
for (const entry of readdirSync(dir, { withFileTypes: true })) {
28-
if (IGNORE_DIRS.has(entry.name)) continue;
2932
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
33+
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
3034
if (entry.isDirectory()) {
3135
files.push(...walkDir(join(dir, entry.name), rel));
3236
} else {

packages/studio/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
33
import { useRenderQueue } from "./components/renders/useRenderQueue";
44
import { usePlayerStore } from "./player";
55
import { LintModal } from "./components/LintModal";
6+
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
67
import { useCaptionStore } from "./captions/store";
78
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
89
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -478,6 +479,13 @@ export function StudioApp() {
478479
onExport={() => void renderQueue.startRender()}
479480
/>
480481

482+
{previewPersistence.domEditSaveQueuePaused && (
483+
<SaveQueuePausedBanner
484+
message={previewPersistence.domEditSaveQueuePaused}
485+
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
486+
/>
487+
)}
488+
481489
<div className="flex flex-1 min-h-0">
482490
<StudioLeftSidebar
483491
leftSidebarRef={leftSidebarRef}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
interface SaveQueuePausedBannerProps {
2+
message: string;
3+
onDismiss: () => void;
4+
}
5+
6+
/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
7+
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
8+
return (
9+
<div
10+
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"
11+
role="alert"
12+
>
13+
<span>{message}</span>
14+
<button
15+
type="button"
16+
onClick={onDismiss}
17+
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
18+
>
19+
Dismiss
20+
</button>
21+
</div>
22+
);
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
2+
3+
export const PROPERTY_DEFAULTS: Record<string, number> = {
4+
opacity: 1,
5+
x: 0,
6+
y: 0,
7+
scale: 1,
8+
scaleX: 1,
9+
scaleY: 1,
10+
rotation: 0,
11+
width: 100,
12+
height: 100,
13+
};
14+
15+
export function ensureElementAddressable(selection: DomEditSelection): {
16+
selector: string;
17+
autoId?: string;
18+
} {
19+
if (selection.id) return { selector: `#${selection.id}` };
20+
if (selection.selector) return { selector: selection.selector };
21+
22+
const el = selection.element;
23+
const doc = el.ownerDocument;
24+
const tag = el.tagName.toLowerCase();
25+
let id = tag;
26+
let n = 1;
27+
while (doc.getElementById(id)) {
28+
n += 1;
29+
id = `${tag}-${n}`;
30+
}
31+
el.setAttribute("id", id);
32+
return { selector: `#${id}`, autoId: id };
33+
}

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
66
import { trackStudioEvent } from "../utils/studioTelemetry";
77
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
88
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
9+
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
910
import {
1011
buildDomEditPatchTarget,
1112
getDomEditTargetKey,
@@ -31,8 +32,10 @@ import {
3132
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
3233
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
3334
import type { EditHistoryKind } from "../utils/editHistory";
35+
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
3436
import { useDomEditTextCommits } from "./useDomEditTextCommits";
3537

38+
// ── Helpers ──
3639
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
3740

3841
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
@@ -69,6 +72,8 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
6972
return false;
7073
}
7174

75+
// ── Types ──
76+
7277
interface RecordEditInput {
7378
label: string;
7479
kind: EditHistoryKind;
@@ -102,6 +107,7 @@ export interface UseDomEditCommitsParams {
102107
projectIdRef: React.MutableRefObject<string | null>;
103108
reloadPreview: () => void;
104109

110+
// From useDomSelection
105111
domEditSelection: DomEditSelection | null;
106112
applyDomSelection: (
107113
selection: DomEditSelection | null,
@@ -115,6 +121,8 @@ export interface UseDomEditCommitsParams {
115121
) => Promise<DomEditSelection | null>;
116122
}
117123

124+
// ── Hook ──
125+
118126
export function useDomEditCommits({
119127
activeCompPath,
120128
previewIframeRef,
@@ -172,7 +180,9 @@ export function useDomEditCommits({
172180
const readResponse = await fetch(
173181
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
174182
);
175-
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
183+
if (!readResponse.ok) {
184+
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
185+
}
176186
const readData = (await readResponse.json()) as { content?: string };
177187
const originalContent = readData.content;
178188
if (typeof originalContent !== "string") {
@@ -196,14 +206,15 @@ export function useDomEditCommits({
196206
body: JSON.stringify({ target: patchTarget, operations }),
197207
},
198208
);
199-
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
209+
if (!patchResponse.ok) {
210+
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
211+
}
200212

201213
const patchData = (await patchResponse.json()) as {
202214
ok?: boolean;
203215
changed?: boolean;
204216
matched?: boolean;
205217
content?: string;
206-
path?: string;
207218
};
208219

209220
if (!patchData.changed) {
@@ -243,7 +254,6 @@ export function useDomEditCommits({
243254
coalesceKey: options?.coalesceKey,
244255
files: { [targetPath]: { before: originalContent, after: finalContent } },
245256
});
246-
showToast(`Updated ${patchData.path ?? targetPath}`, "info");
247257

248258
if (!options?.skipRefresh) {
249259
reloadPreview();
@@ -256,7 +266,6 @@ export function useDomEditCommits({
256266
projectIdRef,
257267
domEditSaveTimestampRef,
258268
reloadPreview,
259-
showToast,
260269
],
261270
);
262271

@@ -282,38 +291,12 @@ export function useDomEditCommits({
282291
resolveImportedFontAsset,
283292
});
284293

285-
// ── Position patch helper ──
286-
287-
// fallow-ignore-next-line complexity
288-
const commitPositionPatchToHtml = useCallback(
289-
(
290-
selection: DomEditSelection,
291-
patches: PatchOperation[],
292-
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
293-
) => {
294-
return queueDomEditSave(async () => {
295-
await persistDomEditOperations(selection, patches, {
296-
label: options.label,
297-
coalesceKey: options.coalesceKey,
298-
skipRefresh: options.skipRefresh ?? true,
299-
});
300-
// fallow-ignore-next-line complexity
301-
}).catch((error) => {
302-
const message = error instanceof Error ? error.message : "Failed to save position";
303-
showToast(message);
304-
trackStudioEvent("save_failure", {
305-
source: "dom_edit",
306-
label: options.label,
307-
error_message: message,
308-
target_id: selection.id ?? undefined,
309-
target_selector: selection.selector ?? undefined,
310-
target_source_file: selection.sourceFile ?? undefined,
311-
});
312-
throw error;
313-
});
314-
},
315-
[persistDomEditOperations, queueDomEditSave, showToast],
316-
);
294+
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
295+
activeCompPath,
296+
persistDomEditOperations,
297+
queueDomEditSave,
298+
showToast,
299+
});
317300

318301
// ── Position commits ──
319302

@@ -426,7 +409,9 @@ export function useDomEditCommits({
426409
const response = await fetch(
427410
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
428411
);
429-
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
412+
if (!response.ok) {
413+
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
414+
}
430415

431416
const data = (await response.json()) as { content?: string };
432417
const originalContent = data.content;
@@ -447,7 +432,12 @@ export function useDomEditCommits({
447432
body: JSON.stringify({ target: patchTarget }),
448433
},
449434
);
450-
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
435+
if (!removeResponse.ok) {
436+
throw await createStudioSaveHttpError(
437+
removeResponse,
438+
`Failed to delete element from ${targetPath}`,
439+
);
440+
}
451441

452442
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
453443
const patchedContent =
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback } from "react";
2+
import type { DomEditSelection } from "../components/editor/domEditing";
3+
import type { PatchOperation } from "../utils/sourcePatcher";
4+
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
5+
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
6+
import type { PersistDomEditOperations } from "./useDomEditCommits";
7+
8+
interface UseDomEditPositionPatchCommitParams {
9+
activeCompPath: string | null;
10+
persistDomEditOperations: PersistDomEditOperations;
11+
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
12+
showToast: (message: string, tone?: "error" | "info") => void;
13+
}
14+
15+
interface PositionPatchOptions {
16+
label: string;
17+
coalesceKey: string;
18+
skipRefresh?: boolean;
19+
}
20+
21+
export function useDomEditPositionPatchCommit({
22+
activeCompPath,
23+
persistDomEditOperations,
24+
queueDomEditSave,
25+
showToast,
26+
}: UseDomEditPositionPatchCommitParams) {
27+
return useCallback(
28+
(selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
29+
return queueDomEditSave(async () => {
30+
await persistDomEditOperations(selection, patches, {
31+
label: options.label,
32+
coalesceKey: options.coalesceKey,
33+
skipRefresh: options.skipRefresh ?? true,
34+
});
35+
}).catch((error) => {
36+
if (error instanceof DomEditSaveQueueOpenError) return;
37+
showToast(error instanceof Error ? error.message : "Failed to save position");
38+
trackStudioSaveFailure({
39+
source: "dom_edit",
40+
error,
41+
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
42+
mutationType: "position",
43+
label: options.label,
44+
targetId: selection.id,
45+
targetSelector: selection.selector,
46+
targetSourceFile: selection.sourceFile,
47+
});
48+
throw error;
49+
});
50+
},
51+
[activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
52+
);
53+
}

0 commit comments

Comments
 (0)