FN-7861: make pause/unpause task state update the board immediately
Patch useTasks pauseTask/unpauseTask to update local hook state and the project SWR task cache immediately on API success, instead of waiting for SSE/poll to reconcile paused state. - pauseTask/unpauseTask now bump fetchVersionRef, patch the in-memory tasks list, and patch/clear the project SWR cache the same way retryTask/bypassReview already do - guards against stale in-flight fetches clobbering the just-applied paused/unpaused state and against missing-id cache entries - adds regression tests covering immediate local+cache reflection for pause and unpause, stale in-flight fetch ordering, and missing-id stability - adds a patch changeset documenting the user-facing fix Files changed: .changeset/fn-7861-immediate-pause-state.md | 7 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 151 +++++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 66 ++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7861 Fusion-Task-Lineage: fefbaf4f-8eb7-44a7-a1e2-ac8471a726bd Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7861-immediate-pause-state.md
Normal file
7
.changeset/fn-7861-immediate-pause-state.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Unpausing (and pausing) a task now updates the board immediately.
|
||||
category: fix
|
||||
dev: useTasks pauseTask/unpauseTask patch shared task state + SWR cache on API success (FN-7861), mirroring retryTask/bypassReview; no longer waits for SSE/poll.
|
||||
@@ -34,6 +34,8 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
bypassReview: vi.fn(),
|
||||
pauseTask: vi.fn(),
|
||||
unpauseTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
@@ -53,6 +55,8 @@ const mockCreateTask = vi.mocked(api.createTask);
|
||||
const mockDeleteTask = vi.mocked(api.deleteTask);
|
||||
const mockRetryTask = vi.mocked(api.retryTask);
|
||||
const mockBypassReview = vi.mocked(api.bypassReview);
|
||||
const mockPauseTask = vi.mocked(api.pauseTask);
|
||||
const mockUnpauseTask = vi.mocked(api.unpauseTask);
|
||||
const mockDuplicateTask = vi.mocked(api.duplicateTask);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
|
||||
@@ -104,6 +108,8 @@ beforeEach(() => {
|
||||
mockFetchArchivedTasks.mockReset().mockResolvedValue({ tasks: [], total: 0, hasMore: false });
|
||||
mockDeleteTask.mockReset();
|
||||
mockRetryTask.mockReset();
|
||||
mockPauseTask.mockReset();
|
||||
mockUnpauseTask.mockReset();
|
||||
mockReadCache.mockReset();
|
||||
mockWriteCache.mockReset();
|
||||
mockClearCache.mockReset();
|
||||
@@ -1830,6 +1836,151 @@ describe("useTasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseTask and unpauseTask", () => {
|
||||
it("FN-7861 immediately reflects unpaused state locally and in the project SWR cache without SSE", async () => {
|
||||
const paused = createMockTask({
|
||||
id: "FN-PAUSE",
|
||||
column: "todo" as Column,
|
||||
paused: true,
|
||||
userPaused: true,
|
||||
pausedByAgentId: null,
|
||||
pausedReason: "operator",
|
||||
});
|
||||
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
|
||||
const unpaused = createMockTask({
|
||||
...paused,
|
||||
paused: false,
|
||||
userPaused: false,
|
||||
pausedByAgentId: null,
|
||||
pausedReason: null,
|
||||
updatedAt: "2026-07-12T00:00:00.000Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([paused, keep]);
|
||||
mockUnpauseTask.mockResolvedValueOnce(unpaused);
|
||||
|
||||
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
|
||||
|
||||
await waitFor(() => expect(result.current.tasks).toHaveLength(2));
|
||||
mockReadCache.mockClear();
|
||||
mockWriteCache.mockClear();
|
||||
mockClearCache.mockClear();
|
||||
mockReadCache.mockReturnValueOnce([paused, keep]);
|
||||
|
||||
let returned: Task | undefined;
|
||||
await act(async () => {
|
||||
returned = await result.current.unpauseTask("FN-PAUSE");
|
||||
});
|
||||
|
||||
expect(mockUnpauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1");
|
||||
expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: false, userPaused: false }));
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(unpaused);
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(false);
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(false);
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteCache).toHaveBeenCalledWith(
|
||||
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
|
||||
[unpaused, keep],
|
||||
{ maxBytes: 500_000 },
|
||||
);
|
||||
expect(mockClearCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-7861 immediately reflects paused state locally and in the project SWR cache without SSE", async () => {
|
||||
const unpaused = createMockTask({
|
||||
id: "FN-PAUSE",
|
||||
column: "todo" as Column,
|
||||
paused: false,
|
||||
userPaused: false,
|
||||
pausedByAgentId: null,
|
||||
pausedReason: null,
|
||||
});
|
||||
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
|
||||
const paused = createMockTask({
|
||||
...unpaused,
|
||||
paused: true,
|
||||
userPaused: true,
|
||||
pausedByAgentId: null,
|
||||
pausedReason: "operator",
|
||||
updatedAt: "2026-07-12T00:01:00.000Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([unpaused, keep]);
|
||||
mockPauseTask.mockResolvedValueOnce(paused);
|
||||
|
||||
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
|
||||
|
||||
await waitFor(() => expect(result.current.tasks).toHaveLength(2));
|
||||
mockReadCache.mockClear();
|
||||
mockWriteCache.mockClear();
|
||||
mockClearCache.mockClear();
|
||||
mockReadCache.mockReturnValueOnce([unpaused, keep]);
|
||||
|
||||
let returned: Task | undefined;
|
||||
await act(async () => {
|
||||
returned = await result.current.pauseTask("FN-PAUSE");
|
||||
});
|
||||
|
||||
expect(mockPauseTask).toHaveBeenCalledWith("FN-PAUSE", "proj-1");
|
||||
expect(returned).toEqual(expect.objectContaining({ id: "FN-PAUSE", paused: true, userPaused: true }));
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")).toEqual(paused);
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.paused).toBe(true);
|
||||
expect(result.current.tasks.find((task) => task.id === "FN-PAUSE")?.userPaused).toBe(true);
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteCache).toHaveBeenCalledWith(
|
||||
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
|
||||
[paused, keep],
|
||||
{ maxBytes: 500_000 },
|
||||
);
|
||||
expect(mockClearCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an older in-flight fetch restore stale paused state after unpause", async () => {
|
||||
const paused = createMockTask({ id: "FN-PAUSE", column: "todo" as Column, paused: true, userPaused: true });
|
||||
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column });
|
||||
const unpaused = createMockTask({ ...paused, paused: false, userPaused: false, updatedAt: "2026-07-12T00:02:00.000Z" });
|
||||
let resolveRefresh!: (tasks: Task[]) => void;
|
||||
mockReadCache.mockReturnValue([paused, keep]);
|
||||
mockFetchTasks.mockImplementationOnce(() => new Promise<Task[]>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}));
|
||||
mockUnpauseTask.mockResolvedValueOnce(unpaused);
|
||||
|
||||
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
|
||||
|
||||
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-PAUSE", "FN-KEEP"]);
|
||||
await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unpauseTask("FN-PAUSE");
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toEqual([unpaused, keep]);
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh([paused, keep]);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toEqual([unpaused, keep]);
|
||||
});
|
||||
|
||||
it("leaves missing-id task collections stable after pause success", async () => {
|
||||
const keep = createMockTask({ id: "FN-KEEP", column: "in-progress" as Column, paused: false, userPaused: false });
|
||||
const pausedMissing = createMockTask({ id: "FN-MISSING", column: "todo" as Column, paused: true, userPaused: true });
|
||||
mockFetchTasks.mockResolvedValueOnce([keep]);
|
||||
mockPauseTask.mockResolvedValueOnce(pausedMissing);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => expect(result.current.tasks).toEqual([keep]));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.pauseTask("FN-MISSING");
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toEqual([keep]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryTask", () => {
|
||||
it("FN-7295 immediately replaces every matching local retry task without SSE or refresh", async () => {
|
||||
const failedOne = createMockTask({
|
||||
|
||||
@@ -744,12 +744,74 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition));
|
||||
}, [projectId]);
|
||||
|
||||
/*
|
||||
FNXC:DashboardPauseState 2026-07-12-00:00:
|
||||
FN-7861 makes pause and unpause user-visible state boundaries. After the API confirms either transition, patch shared hook state and the project SWR task cache immediately, mirroring retryTask/bypassReview, so Board/List/right-dock task renderers do not wait for SSE or polling to clear stale paused rendering.
|
||||
*/
|
||||
const pauseTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.pauseTask(id, projectId));
|
||||
const updatedTask = normalizeTask(await api.pauseTask(id, projectId));
|
||||
fetchVersionRef.current++;
|
||||
|
||||
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
|
||||
|
||||
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 cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
|
||||
if (cacheContainsOnlyTaskRows) {
|
||||
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
|
||||
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
|
||||
} else {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
} else if (cachedTasks === null) {
|
||||
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
|
||||
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
|
||||
} else {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
setTasks((prev) => {
|
||||
const next = projectUpdatedTasks(prev);
|
||||
tasksRef.current = next;
|
||||
return next;
|
||||
});
|
||||
return updatedTask;
|
||||
}, [projectId]);
|
||||
|
||||
const unpauseTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.unpauseTask(id, projectId));
|
||||
const updatedTask = normalizeTask(await api.unpauseTask(id, projectId));
|
||||
fetchVersionRef.current++;
|
||||
|
||||
const projectUpdatedTasks = (currentTasks: Task[]) => currentTasks.map((task) => (task.id === id ? updatedTask : task));
|
||||
|
||||
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 cacheContainsOnlyTaskRows = cachedTasks.every((task) => Boolean(task && typeof task === "object" && typeof (task as Task).id === "string"));
|
||||
if (cacheContainsOnlyTaskRows) {
|
||||
const nextCachedTasks = cachedTasks.map((task) => ((task as Task).id === id ? updatedTask : normalizeTask(task as Task)));
|
||||
writeCache(cacheKey, nextCachedTasks.length > 500 ? nextCachedTasks.slice(0, 500) : nextCachedTasks, { maxBytes: 500_000 });
|
||||
} else {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
} else if (cachedTasks === null) {
|
||||
const nextCurrentTasks = projectUpdatedTasks(tasksRef.current);
|
||||
writeCache(cacheKey, nextCurrentTasks.length > 500 ? nextCurrentTasks.slice(0, 500) : nextCurrentTasks, { maxBytes: 500_000 });
|
||||
} else {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
setTasks((prev) => {
|
||||
const next = projectUpdatedTasks(prev);
|
||||
tasksRef.current = next;
|
||||
return next;
|
||||
});
|
||||
return updatedTask;
|
||||
}, [projectId]);
|
||||
|
||||
const deleteTask = useCallback(async (
|
||||
|
||||
Reference in New Issue
Block a user