fix(studio): a failed prepared-content write is a failed commit, not saved

The server patch is already durable, so keep it and its undo entry and keep
the preview matching the file, but throw after history so the caller cannot
report success (the cutout would say Sound kept with no audio saved).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-01 18:24:56 -07:00
co-authored by Claude Sonnet 5.5
parent 2d690968cc
commit 396f2fbf80
5 changed files with 57 additions and 10 deletions
@@ -217,6 +217,32 @@ describe("canvas edit commits share the file queue", () => {
expect(project.wholeFileWrites()).toBe(1);
});
it("reports a failed follow-up write as a failed commit, not as saved", async () => {
const project = fakeProject();
project.release();
const failing = {
...project,
writeProjectFile: async () => {
throw new Error("409 conflict");
},
};
const { hook, selection, toasts } = mountCanvasCommits(failing);
const saved = await hook.handleDomAttributeBatchCommit(
selection,
[{ type: "attribute", property: "data-note", value: "1" }],
{
label: "Edit with prepared content",
prepareContent: (html) => html.replace("</head>", '<meta data-prepared="1"></head>'),
},
);
expect(saved).toBe(false);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain("couldn't finish updating");
expect(project.read()).not.toContain("data-prepared");
});
it("lands a canvas commit started while a timeline save is writing", async () => {
const project = fakeProject();
const { hook, toasts } = mountCanvasCommits(project);
@@ -19,6 +19,15 @@ export class DomEditPersistUnsafeValueError extends Error {
}
}
export class DomEditPersistPreparedWriteError extends Error {
readonly alreadyToasted = true;
constructor(targetPath: string) {
super(`The edit saved but its follow-up write to ${targetPath} did not`);
this.name = "DomEditPersistPreparedWriteError";
}
}
export type DomEditPersistFailureSelection = Pick<
DomEditSelection,
"label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
@@ -48,7 +57,9 @@ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
export function wasAlreadyToasted(error: unknown): boolean {
return (
(error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
(error instanceof DomEditPersistUnsafeValueError ||
error instanceof DomEditPersistPreparedWriteError ||
error instanceof StudioSaveHttpError) &&
error.alreadyToasted
);
}
@@ -6,7 +6,10 @@ import {
type DomEditSelection,
} from "../components/editor/domEditing";
import type { CommitDomAttributeBatch, PersistDomEditOperations } from "./domEditCommitTypes";
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
import {
DomEditPersistPreparedWriteError,
reportDomEditPersistFailure,
} from "./domEditPersistFailure";
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
@@ -277,7 +280,8 @@ export function useDomEditAttributeCommits({
skipRefresh: options.skipRefresh,
prepareContent: options.prepareContent,
}),
shouldRevert: () => isLatestCommit(),
shouldRevert: (error) =>
isLatestCommit() && !(error instanceof DomEditPersistPreparedWriteError),
revert: () => {
if (!captured) return;
for (const entry of entries) {
@@ -204,17 +204,17 @@ export async function writePreparedContent(
prepare: (html: string, sourceFile: string) => string,
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
showToast: ShowToast,
): Promise<string> {
): Promise<{ content: string; failed: boolean }> {
const preparedContent = prepare(patchedContent, targetPath);
if (preparedContent === patchedContent) return patchedContent;
if (preparedContent === patchedContent) return { content: patchedContent, failed: false };
try {
await writeProjectFile(targetPath, preparedContent, patchedContent);
return preparedContent;
return { content: preparedContent, failed: false };
} catch (error) {
showToast(
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
"error",
);
return patchedContent;
return { content: patchedContent, failed: true };
}
}
@@ -6,6 +6,7 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
import type { PatchOperation } from "../utils/sourcePatcher";
import {
DomEditPersistUnsafeValueError,
DomEditPersistPreparedWriteError,
DomEditPersistUnresolvableError,
warnDomEditPersistNoOp,
} from "./domEditPersistFailure";
@@ -139,6 +140,7 @@ export function useDomEditPersist({
coalesceMs: options?.coalesceMs,
};
const prepare = options?.prepareContent;
let preparedWriteFailed = false;
// Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
const originalContent = await readTarget();
@@ -148,7 +150,7 @@ export function useDomEditPersist({
const patchedContent =
typeof patchData.content === "string" ? patchData.content : originalContent;
const finalContent = prepare
const prepared = prepare
? await writePreparedContent(
targetPath,
patchedContent,
@@ -156,7 +158,9 @@ export function useDomEditPersist({
writeProjectFile,
showToast,
)
: patchedContent;
: { content: patchedContent, failed: false };
preparedWriteFailed = prepared.failed;
const finalContent = prepared.content;
await editHistory.recordEdit({
...history,
@@ -194,7 +198,7 @@ export function useDomEditPersist({
if (!options?.skipRefresh) {
reloadPreview();
}
return completePersistence(
const outcome = completePersistence(
finalContent === patchedContent &&
typeof patchData.path === "string" &&
typeof patchData.version === "string"
@@ -202,6 +206,8 @@ export function useDomEditPersist({
: undefined,
true,
);
if (preparedWriteFailed) throw new DomEditPersistPreparedWriteError(targetPath);
return outcome;
},
[
activeCompPath,