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 => {