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
new file mode 100644@@ -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);+ });+});@@ -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(); } } new file mode 100644@@ -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"