collective

Browse / github.com/storytold/artcraft / patch

fix(moodboard): prevent deleted boards returning after pending saves

fix(moodboard): prevent deleted boards returning after pending saves Prevent deleted moodboards returning after pending saves and stale hydration listings. Validation: 8 new regression cases (6 fail on unchanged main); all 46 moodboard tests pass. Scoped strict TypeScript, ESLint and Prettier pass. Nx module-boundary checking skipped without a cached graph. No live backend or full Tauri run. Implemented with OpenAI Codex (GPT-6) assistance.

Status
open
Score
0.00
Adopters
0 other operators
Author
umairrrkhan (imported from pull request #2477, open)
Base
7a2935cf9e3922e90e4ed5f3556b1cb107013148
Size
+224 −13 in 2 files
Review
pending
Shared
2026-10-28
Id
3ca7f4e6b75be4311fc16b55

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.spec.ts frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.ts

diff --git a/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.spec.ts b/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.spec.ts@@ -0,0 +1,194 @@+import { act, cleanup, renderHook } from "@testing-library/react";+import {+  afterEach,+  beforeEach,+  describe,+  expect,+  it,+  vi,+  type Mock,+} from "vitest";+import type { MoodboardPersistenceAdapter } from "../adapter";++const TOMBSTONE_STORAGE_KEY = "artcraft_moodboard_deleted_tokens_v1";++describe("moodboard deletion during a save", () => {+  let sync: typeof import("./moodboardSync");+  let library: typeof import("../boards/BoardLibraryStore").useBoardLibraryStore;+  let persistence: MoodboardPersistenceAdapter;+  let removeRemote: Mock<(token: string) => Promise<boolean>>;+  let finishSave: (result: { success: boolean; token?: string }) => void;+  let pageHideListeners: EventListener[];++  beforeEach(async () => {+    vi.resetModules();+    vi.useFakeTimers();+    localStorage.clear();+    pageHideListeners = [];+    const addEventListener = window.addEventListener.bind(window);+    vi.spyOn(window, "addEventListener").mockImplementation(+      (type, listener, options) => {+        if (type === "pagehide")+          pageHideListeners.push(listener as EventListener);+        addEventListener(type, listener, options);+      },+    );+    vi.spyOn(console, "error").mockImplementation(() => undefined);+    sync = await import("./moodboardSync");+    library = (await import("../boards/BoardLibraryStore"))+      .useBoardLibraryStore;+    removeRemote = vi.fn().mockResolvedValue(true);+    persistence = {+      getUserId: () => "user-1",+      listBoards: vi.fn().mockResolvedValue({ success: true, boards: [] }),+      loadBoard: vi.fn(),+      saveBoard: vi.fn().mockImplementation(+        () =>+          new Promise((resolve) => {+            finishSave = resolve;+          }),+      ),+      deleteBoard: removeRemote,+    };+  });++  afterEach(() => {+    cleanup();+    for (const listener of pageHideListeners) {+      window.removeEventListener("pagehide", listener);+    }+    vi.clearAllTimers();+    vi.useRealTimers();+    vi.restoreAllMocks();+  });++  it("deletes the remote row created after its local board was deleted", async () => {+    const boardId = await startSave();++    await act(async () => {+      await sync.deleteBoardEverywhere(boardId);+    });+    expect(persistence.deleteBoard).not.toHaveBeenCalled();+    await act(async () => {+      finishSave({ success: true, token: "remote-1" });+    });++    expect(library.getState().boards[boardId]).toBeUndefined();+    expect(persistence.deleteBoard).toHaveBeenCalledExactlyOnceWith("remote-1");+    expect(readTombstones()).toEqual([]);+  });++  it.each(["failure", "rejection", "unsupported"])(+    "keeps a durable tombstone when remote deletion ends in %s",+    async (outcome) => {+      if (outcome === "failure") removeRemote.mockResolvedValue(false);+      if (outcome === "rejection")+        removeRemote.mockRejectedValue(new Error("offline"));+      if (outcome === "unsupported") persistence.deleteBoard = undefined;+      const boardId = await startSave();++      await act(async () => {+        await sync.deleteBoardEverywhere(boardId);+      });+      await act(async () => {+        finishSave({ success: true, token: "remote-1" });+      });++      expect(library.getState().boards[boardId]).toBeUndefined();+      expect(readTombstones()).toEqual(["remote-1"]);+    },+  );++  it("does not delete a board that still exists when its save completes", async () => {+    const boardId = await startSave();++    await act(async () => {+      finishSave({ success: true, token: "remote-1" });+    });++    expect(library.getState().boards[boardId].remoteToken).toBe("remote-1");+    expect(library.getState().boards[boardId].needsSync).toBe(false);+    expect(persistence.deleteBoard).not.toHaveBeenCalled();+    expect(readTombstones()).toEqual([]);+  });++  it("repeats deletion if an update finishes after a linked board was deleted", async () => {+    const boardId = await startSave("existing-remote");++    await act(async () => {+      await sync.deleteBoardEverywhere(boardId);+    });+    expect(persistence.deleteBoard).toHaveBeenCalledExactlyOnceWith(+      "existing-remote",+    );+    // Updates need not return a token; the original board already has it.+    await act(async () => {+      finishSave({ success: true });+    });++    expect(persistence.deleteBoard).toHaveBeenCalledTimes(2);+    expect(persistence.deleteBoard).toHaveBeenLastCalledWith("existing-remote");+    expect(readTombstones()).toEqual([]);+  });++  it.each([true, false])(+    "does not restore a tombstoned board from the pre-delete listing (retry succeeds: %s)",+    async (deleted) => {+      localStorage.setItem(TOMBSTONE_STORAGE_KEY, JSON.stringify(["remote-1"]));+      vi.mocked(persistence.listBoards).mockResolvedValue({+        success: true,+        boards: [+          {+            token: "remote-1",+            name: "Deleted board",+            updatedAt: "2026-10-19T00:00:00Z",+          },+        ],+      });+      removeRemote.mockResolvedValue(deleted);+      vi.mocked(persistence.loadBoard).mockResolvedValue({+        success: true,+        documentJson: JSON.stringify({+          version: 1,+          board: { name: "Deleted board" },+        }),+      });++      renderHook(() =>+        sync.useMoodboardSync({+          sendToGeneration: () => undefined,+          persistence,+        }),+      );+      await act(async () => undefined);++      expect(persistence.deleteBoard).toHaveBeenCalledExactlyOnceWith(+        "remote-1",+      );+      expect(persistence.loadBoard).not.toHaveBeenCalled();+      expect(library.getState().boardOrder).toEqual([]);+      expect(readTombstones()).toEqual(deleted ? [] : ["remote-1"]);+    },+  );++  async function startSave(remoteToken?: string): Promise<string> {+    const boardId = library.getState().createBoard("Draft");+    if (remoteToken)+      library.getState().setBoardRemoteToken(boardId, remoteToken);+    const hook = renderHook(() =>+      sync.useMoodboardSync({+        sendToGeneration: () => undefined,+        persistence,+      }),+    );+    // Finish the initial empty remote listing before starting a manual save.+    await act(async () => undefined);+    act(() => hook.result.current.saveNow());+    expect(persistence.saveBoard).toHaveBeenCalledTimes(1);+    return boardId;+  }++  function readTombstones(): string[] {+    return JSON.parse(localStorage.getItem(TOMBSTONE_STORAGE_KEY) ?? "[]");+  }+});diff --git a/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.ts b/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.ts--- a/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.ts+++ b/frontend/libs/components/moodboard/src/lib/persistence/moodboardSync.ts@@ -3,10 +3,7 @@ import { create } from "zustand"; import { useBoardLibraryStore } from "../boards/BoardLibraryStore"; import type { Board } from "../boards/boardTypes"; import { useMoodboardStore } from "../canvas/MoodboardStore";-import type {-  MoodboardAdapter,-  MoodboardPersistenceAdapter,-} from "../adapter";+import type { MoodboardAdapter, MoodboardPersistenceAdapter } from "../adapter"; import {   applyResolvedMediaUrls,   collectUnresolvedMediaTokens,@@ -211,14 +208,7 @@ export async function deleteBoardEverywhere(boardId: string): Promise<void> {   if (!token) return;    addTombstone(token);-  if (persistenceRef?.deleteBoard) {-    try {-      const deleted = await persistenceRef.deleteBoard(token);-      if (deleted) removeTombstone(token);-    } catch (error) {-      console.error("[Moodboard] remote board delete failed:", error);-    }-  }+  await deleteRemoteBoard(persistenceRef, token); }  // ---------- dirty tracking + scheduling ----------@@ -387,6 +377,17 @@ async function saveBoard(       console.error("[Moodboard] board save failed:", result.errorMessage);       return false;     }+    // Deletion may have happened before a newly created board received its+    // token. Compensate for the completed write instead of leaving an orphan+    // that the next hydration would restore. Updates can race deletion too.+    if (!useBoardLibraryStore.getState().boards[boardId]) {+      const token = result.token ?? board.remoteToken;+      if (token) {+        addTombstone(token);+        await deleteRemoteBoard(persistence, token);+      }+      return true;+    }     withDirtySuppressed(() => {       const store = useBoardLibraryStore.getState();       if (result.token && !board.remoteToken) {@@ -436,8 +437,11 @@ async function hydrateRemoteBoards(     if (syncUserId !== userId) return;      if (listed.success && listed.boards) {-      await retryTombstonedDeletes(persistence);+      // A successful retry removes its tombstone, but the listing above was+      // fetched before that deletion and can still contain the deleted row.       const tombstones = readTombstones();+      await retryTombstonedDeletes(persistence);+      readTombstones().forEach((token) => tombstones.add(token));       const rows = listed.boards.filter((row) => !tombstones.has(row.token));        // Each board hydrates independently — one bad row/fetch must not@@ -595,6 +599,19 @@ function removeTombstone(token: string): void {   writeTombstones(tokens); } +async function deleteRemoteBoard(+  persistence: MoodboardPersistenceAdapter | null,+  token: string,+): Promise<void> {+  if (!persistence?.deleteBoard) return;+  try {+    const deleted = await persistence.deleteBoard(token);+    if (deleted) removeTombstone(token);+  } catch (error) {+    console.error("[Moodboard] remote board delete failed:", error);+  }+}+ async function retryTombstonedDeletes(   persistence: MoodboardPersistenceAdapter, ): Promise<void> {