collective

Browse / github.com/storytold/artcraft / patch

fix(video-editor): retain pending saves after failures

Failed video-project autosave clears dirty state and rejects without a handler SaveManager loses its unsaved-state flag after a storage failure. This is present on main `d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373` and also in #2077. The exit-save fix in #2077 does not introduce this behavior and correctly retains the project on an explicit save failure. ### Reproduction using the actual manager Run Node26 from `frontend` (the manager uses erasable TypeScript and type-only imports): ```js const { SaveManager } = await import('./libs/components/video-editor/src/lib/core/managers/save-manager.ts'); const editor = { project: { getActive: () => ({ metadata: { id: 'draf

Status
open
Score
0.00
Adopters
0 other operators
Author
euisuh (imported from pull request #2349, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+136 −4 in 3 files
Review
pending
Shared
2026-10-28
Id
252780f53a4d2fa589e2888b

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/video-editor/src/lib/core/managers/save-manager-failure.spec.ts frontend/libs/components/video-editor/src/lib/core/managers/save-manager.ts netlify.toml

diff --git a/frontend/libs/components/video-editor/src/lib/core/managers/save-manager-failure.spec.ts b/frontend/libs/components/video-editor/src/lib/core/managers/save-manager-failure.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/video-editor/src/lib/core/managers/save-manager-failure.spec.ts@@ -0,0 +1,122 @@+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";++import type { EditorCore } from "../index";+import { SaveManager } from "./save-manager";++const DEBOUNCE_MS = 10;++describe("SaveManager storage failures", () => {+  const saveCurrentProject = vi.fn<() => Promise<void>>();+  let save: SaveManager;++  beforeEach(() => {+    vi.useFakeTimers();+    saveCurrentProject.mockReset().mockResolvedValue(undefined);+    const editor = {+      project: {+        getActive: () => ({ metadata: { id: "draft" } }),+        getIsLoading: () => false,+        getMigrationState: () => ({ isMigrating: false }),+        saveCurrentProject,+      },+    } as unknown as EditorCore;+    save = new SaveManager({ editor, debounceMs: DEBOUNCE_MS });+  });++  afterEach(() => {+    save.stop();+    vi.restoreAllMocks();+    vi.useRealTimers();+  });++  test("keeps a rejected flush dirty until an explicit retry succeeds", async () => {+    const error = new Error("disk full");+    saveCurrentProject.mockRejectedValueOnce(error);++    await expect(save.flush()).rejects.toBe(error);++    expect(save.getIsDirty()).toBe(true);+    expect(vi.getTimerCount()).toBe(0);+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 100);+    expect(saveCurrentProject).toHaveBeenCalledTimes(1);++    await save.flush();++    expect(saveCurrentProject).toHaveBeenCalledTimes(2);+    expect(save.getIsDirty()).toBe(false);+  });++  test("handles autosave rejection without retrying until another edit", async () => {+    const error = new Error("storage unavailable");+    const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);+    saveCurrentProject.mockRejectedValueOnce(error);++    save.markDirty();+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);++    expect(logError).toHaveBeenCalledWith("Failed to autosave project:", error);+    expect(save.getIsDirty()).toBe(true);+    expect(vi.getTimerCount()).toBe(0);+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 100);+    expect(saveCurrentProject).toHaveBeenCalledTimes(1);++    save.markDirty();+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);++    expect(saveCurrentProject).toHaveBeenCalledTimes(2);+    expect(save.getIsDirty()).toBe(false);+  });++  test("retains edits made during a failed save without scheduling retries", async () => {+    let rejectSave!: (error: Error) => void;+    saveCurrentProject.mockReturnValueOnce(new Promise<void>((_, reject) => {+      rejectSave = reject;+    }));+    const error = new Error("disk full");+    const saving = expect(save.flush()).rejects.toBe(error);+    save.markDirty();+    rejectSave(error);+    await saving;++    expect(save.getIsDirty()).toBe(true);+    expect(vi.getTimerCount()).toBe(0);+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 100);+    expect(saveCurrentProject).toHaveBeenCalledTimes(1);++    await save.flush();++    expect(saveCurrentProject).toHaveBeenCalledTimes(2);+    expect(save.getIsDirty()).toBe(false);+  });++  test("retries the retained pending save when autosave resumes", async () => {+    const error = new Error("storage unavailable");+    saveCurrentProject.mockRejectedValueOnce(error);+    await expect(save.flush()).rejects.toBe(error);++    save.pause();+    save.resume();+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);++    expect(saveCurrentProject).toHaveBeenCalledTimes(2);+    expect(save.getIsDirty()).toBe(false);+  });++  test("still queues edits made during a successful save", async () => {+    let resolveSave!: () => void;+    saveCurrentProject.mockReturnValueOnce(new Promise<void>((resolve) => {+      resolveSave = resolve;+    }));+    const saving = save.flush();+    save.markDirty();+    resolveSave();+    await saving;++    expect(save.getIsDirty()).toBe(true);+    expect(saveCurrentProject).toHaveBeenCalledTimes(1);+    await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);++    expect(saveCurrentProject).toHaveBeenCalledTimes(2);+    expect(save.getIsDirty()).toBe(false);+  });+});diff --git a/frontend/libs/components/video-editor/src/lib/core/managers/save-manager.ts b/frontend/libs/components/video-editor/src/lib/core/managers/save-manager.ts--- a/frontend/libs/components/video-editor/src/lib/core/managers/save-manager.ts+++ b/frontend/libs/components/video-editor/src/lib/core/managers/save-manager.ts@@ -77,7 +77,9 @@ export class SaveManager {       clearTimeout(this.saveTimer);     }     this.saveTimer = setTimeout(() => {-      void this.saveNow();+      void this.saveNow().catch((error) => {+        console.error("Failed to autosave project:", error);+      });     }, this.debounceMs);   } @@ -96,11 +98,14 @@ export class SaveManager {      try {       await this.editor.project.saveCurrentProject();+    } catch (error) {+      this.hasPendingSave = true;+      throw error;     } finally {       this.isSaving = false;-      if (this.hasPendingSave) {-        this.queueSave();-      }+    }+    if (this.hasPendingSave) {+      this.queueSave();     }   } diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,5 @@+# The legacy storyteller-docs build script and docs now live in+# storytold/artcraft-services. Skip only while that script is absent.+# Remove this guard after the Netlify site is repointed to that repository.+[build]+  ignore = "test ! -f script/netlify_build_docs.sh"