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:
7
.changeset/fn-7250-delete-refresh.md
Normal file
7
.changeset/fn-7250-delete-refresh.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user