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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 19:36:01 -07:00
parent ffe2092109
commit e4a9dc6df5
5 changed files with 294 additions and 1 deletions

View File

@@ -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.

View File

@@ -98,6 +98,11 @@ vi.mock("../Column", () => ({
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
{tasks.map((task) => (
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
{task.title ?? task.description ?? task.id}
</article>
))}
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
{onDoneSortModeChange && <button type="button" onClick={() => onDoneSortModeChange("task-id-desc")}>sort-{column}-by-id</button>}
</div>
@@ -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(<Board {...createBoardProps({ tasks: [keptTask] })} />);
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",

View File

@@ -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(<DockTaskList tasks={[deleted, kept]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
expect(screen.getByTestId("dock-task-list-row-FN-DELETE")).toBeInTheDocument();
expect(screen.getByTestId("dock-task-list-row-FN-KEEP")).toBeInTheDocument();
rerender(<DockTaskList tasks={[kept]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
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(<DockTaskList tasks={[deleted]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
expect(screen.getByTestId("dock-task-list-row-FN-DELETE")).toBeInTheDocument();
rerender(<DockTaskList tasks={[]} onOpenTask={vi.fn()} addToast={vi.fn()} />);
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.

View File

@@ -48,6 +48,7 @@ async function flushPromises(): Promise<void> {
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<Task[]>((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 = [

View File

@@ -535,7 +535,38 @@ export function useTasks(options?: UseTasksOptions) {
allowResurrection?: boolean;
},
): Promise<Task> => {
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<unknown>(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<MergeResult> => {