feat(FN-723): add optimistic state insertion for createTask and duplicateTask

- Insert new tasks at correct sorted position in useTasks cache immediately on create/duplicate
- Compute insertIndex based on columnMovedAt/createdAt sorting to maintain board order
- Add comprehensive tests for optimistic insertion ordering in useTasks hook
- Cover edge cases: empty columns, single-item columns, and multi-item sorted columns
This commit is contained in:
gsxdsm
2026-04-02 18:47:38 -07:00
parent bce6ca94c2
commit e701e7d900
2 changed files with 111 additions and 2 deletions

View File

@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
duplicateTask: vi.fn(),
updateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
@@ -41,6 +42,8 @@ async function flushPromises(): Promise<void> {
}
const mockFetchTasks = vi.mocked(api.fetchTasks);
const mockCreateTask = vi.mocked(api.createTask);
const mockDuplicateTask = vi.mocked(api.duplicateTask);
const mockUpdateTask = vi.mocked(api.updateTask);
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
@@ -853,6 +856,102 @@ describe("useTasks", () => {
});
});
describe("createTask optimistic insertion", () => {
it("adds task to state immediately", async () => {
mockFetchTasks.mockResolvedValueOnce([]);
const newTask = createMockTask({ id: "FN-010", column: "triage" });
mockCreateTask.mockResolvedValueOnce(newTask);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
await act(async () => {
await result.current.createTask({ description: "New task" });
});
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0].id).toBe("FN-010");
});
it("does not produce duplicates when SSE event arrives", async () => {
mockFetchTasks.mockResolvedValueOnce([]);
const newTask = createMockTask({ id: "FN-010", column: "triage" });
mockCreateTask.mockResolvedValueOnce(newTask);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
await act(async () => {
await result.current.createTask({ description: "New task" });
});
expect(result.current.tasks).toHaveLength(1);
// SSE event arrives with the same task
act(() => {
MockEventSource.instances[0]._emit("task:created", newTask);
});
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0].id).toBe("FN-010");
});
});
describe("duplicateTask optimistic insertion", () => {
it("adds task to state immediately", async () => {
const original = createMockTask({ id: "FN-001", column: "todo" as Column });
mockFetchTasks.mockResolvedValueOnce([original]);
const duplicated = createMockTask({ id: "FN-011", column: "triage", description: "Test task" });
mockDuplicateTask.mockResolvedValueOnce(duplicated);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
await act(async () => {
await result.current.duplicateTask("FN-001");
});
expect(result.current.tasks).toHaveLength(2);
expect(result.current.tasks.find((t) => t.id === "FN-011")).toBeDefined();
});
it("does not produce duplicates when SSE event arrives", async () => {
const original = createMockTask({ id: "FN-001", column: "todo" as Column });
mockFetchTasks.mockResolvedValueOnce([original]);
const duplicated = createMockTask({ id: "FN-011", column: "triage", description: "Test task" });
mockDuplicateTask.mockResolvedValueOnce(duplicated);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
await act(async () => {
await result.current.duplicateTask("FN-001");
});
expect(result.current.tasks).toHaveLength(2);
// SSE event arrives with the same task
act(() => {
MockEventSource.instances[0]._emit("task:created", duplicated);
});
expect(result.current.tasks).toHaveLength(2);
expect(result.current.tasks.filter((t) => t.id === "FN-011")).toHaveLength(1);
});
});
describe("visibility change", () => {
let originalVisibilityState: PropertyDescriptor | undefined;

View File

@@ -208,7 +208,12 @@ export function useTasks(options?: UseTasksOptions) {
}, [connectionNonce, projectId, refreshTasks]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
return normalizeTask(await api.createTask(input, projectId));
const task = normalizeTask(await api.createTask(input, projectId));
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) return prev;
return [...prev, task];
});
return task;
}, [projectId]);
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
@@ -228,7 +233,12 @@ export function useTasks(options?: UseTasksOptions) {
}, [projectId]);
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.duplicateTask(id, projectId));
const task = normalizeTask(await api.duplicateTask(id, projectId));
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) return prev;
return [...prev, task];
});
return task;
}, [projectId]);
const updateTask = useCallback(async (