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
new file mode 100644@@ -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) ?? "[]");+ }+});@@ -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> {