From e4a9dc6df567a700fd3db1c76a6a218bfab75caf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 19:36:01 -0700 Subject: [PATCH] FN-7250: remove deleted tasks from shared task state Keep board and right-dock task lists in sync immediately after a task delete succeeds. - Remove deleted ids from useTasks state as soon as the delete API call resolves. - Update or clear the project task SWR cache to avoid stale remount hydration. - Invalidate older refreshes so late pre-delete snapshots cannot resurrect removed cards. - Cover board columns, right-dock rows, cache updates, rejection handling, archived rows, and late refresh behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7250-delete-refresh.md | 7 + .../app/components/__tests__/Board.test.tsx | 35 ++++ .../app/components/__tests__/DockTaskList.test.tsx | 30 ++++ .../dashboard/app/hooks/__tests__/useTasks.test.ts | 190 +++++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 33 +++- 5 files changed, 294 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7250 Fusion-Task-Lineage: f2271366-7494-402c-9a8b-9927dbe4401c Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7250-delete-refresh.md | 7 + .../app/components/__tests__/Board.test.tsx | 35 ++++ .../__tests__/DockTaskList.test.tsx | 30 +++ .../app/hooks/__tests__/useTasks.test.ts | 190 ++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 33 ++- 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7250-delete-refresh.md diff --git a/.changeset/fn-7250-delete-refresh.md b/.changeset/fn-7250-delete-refresh.md new file mode 100644 index 0000000000..d11f0f0dbe --- /dev/null +++ b/.changeset/fn-7250-delete-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remove deleted tasks from the board and right sidebar immediately after deletion. +category: fix +dev: Updates the dashboard `useTasks` delete path to remove successfully deleted task ids from shared state and project task cache without waiting for SSE/refetch. diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 4934625abe..7b7f381ebe 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -98,6 +98,11 @@ vi.mock("../Column", () => ({ columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1; return (
+ {tasks.map((task) => ( +
+ {task.title ?? task.description ?? task.id} +
+ ))} {onToggleCollapse && } {onDoneSortModeChange && }
@@ -257,6 +262,36 @@ describe("Board", () => { } }); + it("FN-7250 removes cards from columns when the shared task array drops a deleted id", () => { + const deletedTask = { + id: "FN-DELETE", + description: "Deleted", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + } as Task; + const keptTask = { ...deletedTask, id: "FN-KEEP", description: "Kept", column: "in-progress" } as Task; + const readIds = (column: string) => (JSON.parse(screen.getByTestId(`column-${column}`).getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id); + + const { rerender } = renderBoard({ tasks: [deletedTask, keptTask] }); + + expect(readIds("todo")).toEqual(["FN-DELETE"]); + expect(readIds("in-progress")).toEqual(["FN-KEEP"]); + expect(screen.getByTestId("board-task-card-FN-DELETE")).toBeInTheDocument(); + expect(screen.getByTestId("board-task-card-FN-KEEP")).toBeInTheDocument(); + + rerender(); + + expect(readIds("todo")).toEqual([]); + expect(readIds("in-progress")).toEqual(["FN-KEEP"]); + expect(screen.queryByTestId("board-task-card-FN-DELETE")).toBeNull(); + expect(screen.getByTestId("board-task-card-FN-KEEP")).toBeInTheDocument(); + }); + it("falls back malformed task columns to triage instead of crashing", () => { const malformedTask = { id: "FN-404", diff --git a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx index 63c89f60a9..c42a807a87 100644 --- a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx @@ -40,6 +40,36 @@ describe("DockTaskList", () => { expect(onOpenTask).toHaveBeenCalledWith(second); }); + it("FN-7250 removes rows when the shared task array drops a deleted id", () => { + const deleted = makeTask("FN-DELETE", "Deleted task", "todo"); + const kept = makeTask("FN-KEEP", "Kept task", "in-progress"); + + const { rerender } = render(); + + expect(screen.getByTestId("dock-task-list-row-FN-DELETE")).toBeInTheDocument(); + expect(screen.getByTestId("dock-task-list-row-FN-KEEP")).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId("dock-task-list-row-FN-DELETE")).toBeNull(); + expect(screen.getByTestId("dock-task-list-row-FN-KEEP")).toBeInTheDocument(); + expect(screen.queryByTestId("dock-task-list-empty")).toBeNull(); + }); + + it("FN-7250 renders the empty state when the only task is deleted", () => { + const deleted = makeTask("FN-DELETE", "Deleted task", "todo"); + + const { rerender } = render(); + + expect(screen.getByTestId("dock-task-list-row-FN-DELETE")).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId("dock-task-list-row-FN-DELETE")).toBeNull(); + expect(screen.getByTestId("dock-task-list-empty")).toBeInTheDocument(); + expect(screen.getByText("No tasks yet")).toBeInTheDocument(); + }); + /* FNXC:RightDockTasks 2026-06-28-18:38: The right-dock Tasks list is active-by-default: done tasks are opt-in via Show Done, archived tasks never appear, and the incoming active/done order is preserved when completed work is shown. diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 4f9210a92e..0842f69a24 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -48,6 +48,7 @@ async function flushPromises(): Promise { const mockFetchTasks = vi.mocked(api.fetchTasks); const mockCreateTask = vi.mocked(api.createTask); +const mockDeleteTask = vi.mocked(api.deleteTask); const mockDuplicateTask = vi.mocked(api.duplicateTask); const mockUpdateTask = vi.mocked(api.updateTask); const mockArchiveAllDone = vi.mocked(api.archiveAllDone); @@ -96,6 +97,7 @@ beforeEach(() => { MockEventSource.instances = []; (globalThis as any).EventSource = MockEventSource; mockFetchTasks.mockReset().mockResolvedValue([]); + mockDeleteTask.mockReset(); mockReadCache.mockReset(); mockWriteCache.mockReset(); mockClearCache.mockReset(); @@ -1191,6 +1193,194 @@ describe("useTasks", () => { }); }); + describe("deleteTask", () => { + it("FN-7250 removes the deleted id from fetched local state without SSE or refresh", async () => { + const tasks = [ + createMockTask({ id: "FN-KEEP", title: "Keep", column: "in-progress" as Column }), + createMockTask({ id: "FN-DELETE", title: "Delete", column: "todo" as Column }), + ]; + mockFetchTasks.mockResolvedValueOnce(tasks); + mockDeleteTask.mockResolvedValueOnce(createMockTask({ + id: "FN-DELETE", + column: "todo" as Column, + deletedAt: "2026-06-29T18:52:00.000Z", + })); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP", "FN-DELETE"])); + + let deleted: Task | undefined; + await act(async () => { + deleted = await result.current.deleteTask("FN-DELETE"); + }); + + expect(mockDeleteTask).toHaveBeenCalledWith("FN-DELETE", "proj-1", undefined); + expect(deleted?.id).toBe("FN-DELETE"); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP"]); + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + }); + + it("removes every matching task id from populated local state after a successful delete", async () => { + const tasks = [ + createMockTask({ id: "FN-DELETE", title: "Duplicate one", column: "todo" as Column }), + createMockTask({ id: "FN-KEEP", title: "Keep", column: "in-progress" as Column }), + createMockTask({ id: "FN-DELETE", title: "Duplicate two", column: "done" as Column }), + ]; + mockFetchTasks.mockResolvedValueOnce(tasks); + mockDeleteTask.mockResolvedValueOnce(createMockTask({ id: "FN-DELETE", column: "todo" as Column })); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(3)); + + await act(async () => { + await result.current.deleteTask("FN-DELETE"); + }); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP"]); + }); + + it("removes the deleted id from the project SWR task cache after a successful delete", async () => { + const tasks = [ + createMockTask({ id: "FN-DELETE", column: "todo" as Column }), + createMockTask({ id: "FN-KEEP", column: "in-progress" as Column }), + ]; + mockFetchTasks.mockResolvedValueOnce(tasks); + mockDeleteTask.mockResolvedValueOnce(createMockTask({ id: "FN-DELETE", column: "todo" as Column })); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + mockReadCache.mockReturnValueOnce(tasks); + + await act(async () => { + await result.current.deleteTask("FN-DELETE"); + }); + + expect(mockReadCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + { maxAgeMs: swrCache.SWR_TASKS_MAX_AGE_MS }, + ); + expect(mockWriteCache).toHaveBeenCalledWith( + `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`, + [tasks[1]], + { maxBytes: 500_000 }, + ); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it("does not resurrect a deleted task when an older refresh resolves after delete success", async () => { + const deletedTask = createMockTask({ id: "FN-DELETE", column: "todo" as Column }); + const keptTask = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column }); + let resolveRefresh!: (tasks: Task[]) => void; + mockReadCache.mockReturnValue([deletedTask, keptTask]); + mockFetchTasks.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + mockDeleteTask.mockResolvedValueOnce(createMockTask({ + id: "FN-DELETE", + column: "todo" as Column, + deletedAt: "2026-06-29T21:04:00.000Z", + })); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-DELETE", "FN-KEEP"]); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1)); + + await act(async () => { + await result.current.deleteTask("FN-DELETE"); + }); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP"]); + + await act(async () => { + resolveRefresh([deletedTask, keptTask]); + await flushPromises(); + }); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-KEEP"]); + }); + + it("keeps local state and cache untouched when delete rejects", async () => { + const tasks = [ + createMockTask({ id: "FN-DELETE", column: "todo" as Column }), + createMockTask({ id: "FN-KEEP", column: "in-progress" as Column }), + ]; + mockFetchTasks.mockResolvedValueOnce(tasks); + mockDeleteTask.mockRejectedValueOnce(new Error("dependency conflict")); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(2)); + mockReadCache.mockClear(); + mockWriteCache.mockClear(); + mockClearCache.mockClear(); + + await expect( + act(async () => { + await result.current.deleteTask("FN-DELETE", { removeDependencyReferences: true }); + }), + ).rejects.toThrow("dependency conflict"); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-DELETE", "FN-KEEP"]); + expect(mockReadCache).not.toHaveBeenCalled(); + expect(mockWriteCache).not.toHaveBeenCalled(); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it("handles successful deletes against an empty task array and remains idempotent with later SSE deletes", async () => { + mockFetchTasks.mockResolvedValueOnce([]); + mockDeleteTask.mockResolvedValueOnce(createMockTask({ id: "FN-MISSING", column: "todo" as Column })); + + const { result } = renderHook(() => useTasks()); + + await waitFor(() => expect(result.current.tasks).toHaveLength(0)); + + await act(async () => { + await result.current.deleteTask("FN-MISSING"); + }); + + expect(result.current.tasks).toEqual([]); + + act(() => { + MockEventSource.instances[0]._emit("task:deleted", { id: "FN-MISSING" }); + }); + + expect(result.current.tasks).toEqual([]); + }); + + it("removes archived-loaded tasks without disturbing active rows", async () => { + const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); + const archived = createMockTask({ id: "FN-ARCHIVED", column: "archived" as Column }); + mockFetchTasks + .mockResolvedValueOnce([active]) + .mockResolvedValueOnce([active, archived]); + mockDeleteTask.mockResolvedValueOnce(archived); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + + await waitFor(() => expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE"])); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + + expect(result.current.includeArchived).toBe(true); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE", "FN-ARCHIVED"]); + + await act(async () => { + await result.current.deleteTask("FN-ARCHIVED"); + }); + + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE"]); + }); + }); + describe("SSE event: task:deleted", () => { it("removes task from the list", async () => { const tasks = [ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index fa05b62820..b3d85572a3 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -535,7 +535,38 @@ export function useTasks(options?: UseTasksOptions) { allowResurrection?: boolean; }, ): Promise => { - return normalizeTask(await api.deleteTask(id, projectId, options)); + const deletedTask = normalizeTask(await api.deleteTask(id, projectId, options)); + /* + FNXC:TaskDeletion 2026-06-29-18:52: + Local deletes must update the shared useTasks array immediately because the Board and right-dock Tasks list both render from this state and should not wait for SSE or a refetch after the API confirms deletion. + + FNXC:TaskDeletionCache 2026-06-29-20:11: + Project-scoped SWR hydration must remove the deleted task after the API confirms deletion, otherwise an immediate remount can hydrate a stale row before the next fetch. Only the active project's task cache key is touched; if the cached envelope has an unexpected shape, clear that key instead of writing possibly stale data. + + FNXC:TaskDeletionCache 2026-06-29-21:04: + Delete success must also invalidate refreshes that began before the API call completed; otherwise a late pre-delete snapshot can rehydrate the removed card in Board and the right-dock Tasks list until the next live update. + */ + // Invalidate refreshes that started before the delete succeeded so an older + // server snapshot cannot overwrite the locally removed row after this point. + fetchVersionRef.current++; + + if (projectId) { + const cacheKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`; + const cachedTasks = readCache(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS }); + if (Array.isArray(cachedTasks)) { + const nextCachedTasks = cachedTasks.filter((task): task is Task => { + return Boolean(task && typeof task === "object" && (task as Task).id !== id); + }); + writeCache(cacheKey, nextCachedTasks, { maxBytes: 500_000 }); + } else if (cachedTasks === null) { + const nextCurrentTasks = tasksRef.current.filter((task) => task.id !== id); + writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 }); + } else { + clearCache(cacheKey); + } + } + setTasks((prev) => prev.filter((task) => task.id !== id)); + return deletedTask; }, [projectId]); const mergeTask = useCallback(async (id: string): Promise => {