diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx new file mode 100644 index 0000000..24e9ea2 --- /dev/null +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -0,0 +1,625 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getCalendarDateRange } from "@tascal/shared/calendar"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task } from "../types/task"; +import { useTasks, useUnscheduledTasks, useUpdateTask } from "./useTasks"; + +const mockFetchTasks = vi.fn(); +const mockFetchUnscheduledTasks = vi.fn(); +const mockUpdateTask = vi.fn(); + +vi.mock("../api/tasks", () => ({ + fetchTasks: (...args: unknown[]) => mockFetchTasks(...args) as unknown, + fetchUnscheduledTasks: (...args: unknown[]) => + mockFetchUnscheduledTasks(...args) as unknown, + createTask: vi.fn(), + updateTask: (...args: unknown[]) => mockUpdateTask(...args) as unknown, + deleteTask: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn() }), +})); + +const scheduledTask: Task = { + id: "scheduled-task", + userId: "user-1", + title: "予定あり", + description: null, + date: "2026-08-12", + status: "todo", + categoryId: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const unscheduledTask: Task = { + ...scheduledTask, + id: "unscheduled-task", + title: "未スケジュール", + date: null, +}; + +const { startDate, endDate } = getCalendarDateRange(2026, 8); +const rangeKey = ["tasks", "range", startDate, endDate] as const; +const unscheduledKey = ["tasks", "unscheduled"] as const; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity, retry: false }, + }, + }); +} + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function createPendingUpdate() { + let resolve!: (task: Task) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + mockUpdateTask.mockReturnValue(promise); + return { promise, reject, resolve }; +} + +function createDeferredUpdate() { + let resolve!: (task: Task) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +describe("useUpdateTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchTasks.mockResolvedValue([]); + mockFetchUnscheduledTasks.mockResolvedValue([]); + }); + + it("API応答前にカレンダーから未スケジュールへ移し、失敗時に両cacheを戻す", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, [unscheduledTask]); + const pendingUpdate = createPendingUpdate(); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryData(rangeKey)).toEqual([]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([ + unscheduledTask, + { ...scheduledTask, date: null }, + ]); + }); + + pendingUpdate.reject(new Error("update failed")); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]); + }); + + it("API応答前に未スケジュールからカレンダーへ移し、失敗時に両cacheを戻す", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, [unscheduledTask]); + const pendingUpdate = createPendingUpdate(); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const targetDate = "2026-08-20"; + + act(() => { + result.current.mutate({ + id: unscheduledTask.id, + data: { date: targetDate }, + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryData(rangeKey)).toEqual([ + scheduledTask, + { ...unscheduledTask, date: targetDate }, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + }); + + pendingUpdate.reject(new Error("update failed")); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]); + }); + + it.each([ + ["表示範囲の開始日", startDate, true], + ["表示範囲の終了日", endDate, true], + ["表示範囲より前", "2026-07-26", false], + ["表示範囲より後", "2026-09-07", false], + ])("%sへの移動でrange cache所属を判定する", async (_label, date, inRange) => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, []); + queryClient.setQueryData(unscheduledKey, [unscheduledTask]); + mockUpdateTask.mockResolvedValue({ ...unscheduledTask, date }); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await result.current.mutateAsync({ + id: unscheduledTask.id, + data: { date }, + }); + }); + + expect(queryClient.getQueryData(rangeKey)).toEqual( + inRange ? [{ ...unscheduledTask, date }] : [], + ); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + }); + + it("失敗時に対象IDだけを戻し、別taskの並行cache更新を維持する", async () => { + const otherTask = { + ...scheduledTask, + id: "other-task", + title: "別タスク", + }; + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask, otherTask]); + queryClient.setQueryData(unscheduledKey, [unscheduledTask]); + const pendingUpdate = createPendingUpdate(); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + }); + await waitFor(() => + expect(queryClient.getQueryData(rangeKey)).toEqual([otherTask]), + ); + + const externallyUpdatedTask = { ...otherTask, title: "並行更新済み" }; + queryClient.setQueryData(rangeKey, [externallyUpdatedTask]); + pendingUpdate.reject(new Error("update failed")); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + scheduledTask, + externallyUpdatedTask, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]); + }); + + it("異なるIDのmutationは並行実行し、一方のrollbackでも他方を維持する", async () => { + const otherTask = { + ...scheduledTask, + id: "other-task", + title: "別タスク", + }; + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask, otherTask]); + queryClient.setQueryData(unscheduledKey, []); + const failedUpdate = createDeferredUpdate(); + const successfulUpdate = createDeferredUpdate(); + mockUpdateTask.mockImplementation((id: string) => + id === scheduledTask.id ? failedUpdate.promise : successfulUpdate.promise, + ); + const first = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const second = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + first.result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + second.result.current.mutate({ + id: otherTask.id, + data: { title: "別タスク更新済み" }, + }); + }); + + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); + failedUpdate.reject(new Error("first update failed")); + await waitFor(() => expect(first.result.current.isError).toBe(true)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([ + scheduledTask, + { ...otherTask, title: "別タスク更新済み" }, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + + successfulUpdate.resolve({ ...otherTask, title: "別タスク更新済み" }); + await waitFor(() => expect(second.result.current.isSuccess).toBe(true)); + }); + + it("同一IDの先行移動失敗後に後続更新を正しい所属へ反映する", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, []); + const firstUpdate = createDeferredUpdate(); + const secondUpdate = createDeferredUpdate(); + mockUpdateTask + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise); + const first = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const second = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + first.result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + second.result.current.mutate({ + id: scheduledTask.id, + data: { title: "後続更新" }, + }); + }); + + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1)); + firstUpdate.reject(new Error("first update failed")); + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...scheduledTask, title: "後続更新" }, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + + secondUpdate.resolve({ ...scheduledTask, title: "後続更新" }); + await waitFor(() => expect(second.result.current.isSuccess).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...scheduledTask, title: "後続更新" }, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + }); + + it("同一IDの先行移動と後続更新が両方失敗した場合に元の所属へ戻す", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, []); + const firstUpdate = createDeferredUpdate(); + const secondUpdate = createDeferredUpdate(); + mockUpdateTask + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise); + const first = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const second = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + first.result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + second.result.current.mutate({ + id: scheduledTask.id, + data: { title: "後続更新" }, + }); + }); + + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1)); + firstUpdate.reject(new Error("first update failed")); + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); + secondUpdate.reject(new Error("second update failed")); + await waitFor(() => expect(second.result.current.isError).toBe(true)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + }); + + it("失敗時に元がundefinedだったcacheを未取得状態へ戻す", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toBeUndefined(); + const pendingUpdate = createPendingUpdate(); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + }); + await waitFor(() => + expect(queryClient.getQueryData(unscheduledKey)).toEqual([ + { ...scheduledTask, date: null }, + ]), + ); + + pendingUpdate.reject(new Error("update failed")); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toBeUndefined(); + }); + + it("onMutate内部例外後も部分更新を戻し同一IDの後続mutationを完了できる", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, []); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const originalSetQueryData = queryClient.setQueryData.bind(queryClient); + vi.spyOn(queryClient, "setQueryData") + .mockImplementationOnce(originalSetQueryData) + .mockImplementationOnce(() => { + throw new Error("optimistic cache update failed"); + }); + const first = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + first.result.current.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + }); + + await waitFor(() => expect(first.result.current.isError).toBe(true)); + expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + expect(invalidateQueries).toHaveBeenCalledTimes(1); + + const updatedTask = { ...scheduledTask, title: "例外後の更新" }; + mockUpdateTask.mockResolvedValue(updatedTask); + const second = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await second.result.current.mutateAsync({ + id: scheduledTask.id, + data: { title: updatedTask.title }, + }); + }); + + expect(mockUpdateTask).toHaveBeenCalledWith(scheduledTask.id, { + title: updatedTask.title, + }); + await waitFor(() => expect(second.result.current.isSuccess).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([updatedTask]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([]); + expect(invalidateQueries).toHaveBeenCalledTimes(2); + }); + + it("active queryで異なるIDの一方settle後も他方のoptimistic値を維持する", async () => { + const otherTask = { + ...scheduledTask, + id: "other-task", + title: "別タスク", + }; + const updatedOtherTask = { ...otherTask, title: "別タスク更新済み" }; + const movedTask = { ...scheduledTask, date: null }; + mockFetchTasks + .mockResolvedValueOnce([scheduledTask, otherTask]) + .mockResolvedValue([updatedOtherTask]); + mockFetchUnscheduledTasks + .mockResolvedValueOnce([]) + .mockResolvedValue([movedTask]); + const firstUpdate = createDeferredUpdate(); + const secondUpdate = createDeferredUpdate(); + mockUpdateTask.mockImplementation((id: string) => + id === scheduledTask.id ? firstUpdate.promise : secondUpdate.promise, + ); + const queryClient = createQueryClient(); + const { result } = renderHook( + () => ({ + tasks: useTasks(2026, 8), + unscheduledTasks: useUnscheduledTasks(), + first: useUpdateTask(2026, 8), + second: useUpdateTask(2026, 8), + }), + { wrapper: createWrapper(queryClient) }, + ); + + await waitFor(() => { + expect(result.current.tasks.isSuccess).toBe(true); + expect(result.current.unscheduledTasks.isSuccess).toBe(true); + }); + act(() => { + result.current.first.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + result.current.second.mutate({ + id: otherTask.id, + data: { title: updatedOtherTask.title }, + }); + }); + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); + + firstUpdate.resolve(movedTask); + await waitFor(() => expect(result.current.first.isSuccess).toBe(true)); + + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(1); + expect(result.current.tasks.data).toEqual([updatedOtherTask]); + expect(result.current.unscheduledTasks.data).toEqual([movedTask]); + + secondUpdate.resolve(updatedOtherTask); + await waitFor(() => { + expect(result.current.second.isSuccess).toBe(true); + expect(mockFetchTasks).toHaveBeenCalledTimes(2); + expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2); + }); + expect(result.current.tasks.data).toEqual([updatedOtherTask]); + expect(result.current.unscheduledTasks.data).toEqual([movedTask]); + }); + + it("active queryで同一IDの後続値を先行settle中のrefetchで上書きしない", async () => { + const movedTask = { ...scheduledTask, date: null }; + const finalTask = { ...movedTask, title: "後続更新" }; + mockFetchTasks.mockResolvedValueOnce([scheduledTask]).mockResolvedValue([]); + mockFetchUnscheduledTasks + .mockResolvedValueOnce([]) + .mockResolvedValue([finalTask]); + const firstUpdate = createDeferredUpdate(); + const secondUpdate = createDeferredUpdate(); + mockUpdateTask + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise); + const queryClient = createQueryClient(); + const { result } = renderHook( + () => ({ + tasks: useTasks(2026, 8), + unscheduledTasks: useUnscheduledTasks(), + first: useUpdateTask(2026, 8), + second: useUpdateTask(2026, 8), + }), + { wrapper: createWrapper(queryClient) }, + ); + + await waitFor(() => { + expect(result.current.tasks.isSuccess).toBe(true); + expect(result.current.unscheduledTasks.isSuccess).toBe(true); + }); + act(() => { + result.current.first.mutate({ + id: scheduledTask.id, + data: { date: null }, + }); + result.current.second.mutate({ + id: scheduledTask.id, + data: { title: finalTask.title }, + }); + }); + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1)); + + firstUpdate.resolve(movedTask); + await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); + + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(1); + expect(result.current.tasks.data).toEqual([]); + expect(result.current.unscheduledTasks.data).toEqual([finalTask]); + + secondUpdate.resolve(finalTask); + await waitFor(() => { + expect(result.current.second.isSuccess).toBe(true); + expect(mockFetchTasks).toHaveBeenCalledTimes(2); + expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2); + }); + expect(result.current.tasks.data).toEqual([]); + expect(result.current.unscheduledTasks.data).toEqual([finalTask]); + }); + + it("成功時は購読queryの再取得完了を待ちserver確定値を一意に反映する", async () => { + const confirmedTask = { + ...scheduledTask, + title: "サーバー確定値", + date: null, + }; + mockFetchTasks.mockResolvedValueOnce([scheduledTask]).mockResolvedValue([]); + mockFetchUnscheduledTasks + .mockResolvedValueOnce([]) + .mockResolvedValue([confirmedTask]); + mockUpdateTask.mockResolvedValue(confirmedTask); + const queryClient = createQueryClient(); + const { result } = renderHook( + () => ({ + tasks: useTasks(2026, 8), + unscheduledTasks: useUnscheduledTasks(), + updateTask: useUpdateTask(2026, 8), + }), + { wrapper: createWrapper(queryClient) }, + ); + + await waitFor(() => { + expect(result.current.tasks.isSuccess).toBe(true); + expect(result.current.unscheduledTasks.isSuccess).toBe(true); + }); + + await act(async () => { + await result.current.updateTask.mutateAsync({ + id: scheduledTask.id, + data: { date: null }, + }); + }); + + expect(mockFetchTasks).toHaveBeenCalledTimes(2); + expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2); + expect(queryClient.getQueryData(rangeKey)).toEqual([]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([confirmedTask]); + await waitFor(() => { + expect(result.current.tasks.data).toEqual([]); + expect(result.current.unscheduledTasks.data).toEqual([confirmedTask]); + }); + expect( + [ + ...(result.current.tasks.data ?? []), + ...(result.current.unscheduledTasks.data ?? []), + ].filter((task) => task.id === scheduledTask.id), + ).toHaveLength(1); + }); + + it("日付を変えない更新では所属を維持して対象cacheの内容を更新する", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(rangeKey, [scheduledTask]); + queryClient.setQueryData(unscheduledKey, [unscheduledTask]); + mockUpdateTask.mockResolvedValue({ + ...scheduledTask, + title: "更新済み", + description: "説明", + status: "done", + categoryId: "11111111-1111-4111-8111-111111111111", + }); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const data = { + title: "更新済み", + description: "説明", + status: "done" as const, + categoryId: "11111111-1111-4111-8111-111111111111", + }; + + await act(async () => { + await result.current.mutateAsync({ id: scheduledTask.id, data }); + }); + + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...scheduledTask, ...data }, + ]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]); + }); +}); diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts index 02ef60a..98b8f8e 100644 --- a/apps/web/src/hooks/useTasks.ts +++ b/apps/web/src/hooks/useTasks.ts @@ -1,4 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; import { getCalendarDateRange } from "@tascal/shared/calendar"; import type { TaskCreateInput, @@ -19,6 +20,46 @@ function tasksQueryKey(startDate: string, endDate: string) { } const unscheduledTasksQueryKey = ["tasks", "unscheduled"] as const; +type TaskUpdateCoordinator = { + activeCount: number; + locks: Map>; +}; +type TaskUpdateRegistration = { + coordinator: TaskUpdateCoordinator; + id: string; + releaseLock: () => void; + released: boolean; + tail: Promise; +}; +type TaskSnapshot = { + hadData: boolean; + index: number; + task: Task | undefined; +}; +const taskUpdateCoordinators = new WeakMap< + QueryClient, + TaskUpdateCoordinator +>(); + +function releaseTaskUpdate( + queryClient: QueryClient, + registration: TaskUpdateRegistration, +) { + if (registration.released) return; + registration.released = true; + registration.releaseLock(); + + const { coordinator, id, tail } = registration; + if (coordinator.locks.get(id) === tail) { + coordinator.locks.delete(id); + } + coordinator.activeCount -= 1; + + if (coordinator.activeCount === 0) { + taskUpdateCoordinators.delete(queryClient); + return queryClient.invalidateQueries({ queryKey: ["tasks"] }); + } +} export function useTasks(year: number, month: number) { const { startDate, endDate } = getCalendarDateRange(year, month); @@ -82,30 +123,158 @@ export function useUpdateTask(year: number, month: number) { const { startDate, endDate } = getCalendarDateRange(year, month); const key = tasksQueryKey(startDate, endDate); + const isInCurrentRange = (date: string | null) => + date !== null && startDate <= date && date <= endDate; + + const snapshotTask = (tasks: Task[] | undefined, id: string) => { + const index = tasks?.findIndex((task) => task.id === id) ?? -1; + return { + hadData: tasks !== undefined, + index, + task: index >= 0 ? tasks?.[index] : undefined, + }; + }; + + const restoreTask = ( + queryKey: readonly string[], + id: string, + snapshot: TaskSnapshot, + ) => { + queryClient.setQueryData(queryKey, (current) => { + const tasks = (current ?? []).filter((task) => task.id !== id); + if (!snapshot.task) return tasks; + + const index = Math.min(snapshot.index, tasks.length); + return [...tasks.slice(0, index), snapshot.task, ...tasks.slice(index)]; + }); + + if ( + !snapshot.hadData && + queryClient.getQueryData(queryKey)?.length === 0 + ) { + queryClient.removeQueries({ queryKey, exact: true }); + } + }; + return useMutation({ mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => updateTask(id, data), onMutate: async ({ id, data }) => { - await queryClient.cancelQueries({ queryKey: key }); - const previous = queryClient.getQueryData(key); + const coordinator = taskUpdateCoordinators.get(queryClient) ?? { + activeCount: 0, + locks: new Map>(), + }; + coordinator.activeCount += 1; + taskUpdateCoordinators.set(queryClient, coordinator); - queryClient.setQueryData(key, (old) => - (old ?? []).map((task) => - task.id === id ? { ...task, ...data } : task, - ), - ); + const previous = coordinator.locks.get(id) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => current); + coordinator.locks.set(id, tail); + const registration: TaskUpdateRegistration = { + coordinator, + id, + releaseLock: release, + released: false, + tail, + }; + let previousTask: TaskSnapshot | undefined; + let previousUnscheduledTask: TaskSnapshot | undefined; + + try { + await previous; + await Promise.all([ + queryClient.cancelQueries({ queryKey: key }), + queryClient.cancelQueries({ queryKey: unscheduledTasksQueryKey }), + ]); + + const previousTasks = queryClient.getQueryData(key); + const previousUnscheduledTasks = queryClient.getQueryData( + unscheduledTasksQueryKey, + ); + const task = [ + ...(previousTasks ?? []), + ...(previousUnscheduledTasks ?? []), + ].find((candidate) => candidate.id === id); + previousTask = snapshotTask(previousTasks, id); + previousUnscheduledTask = snapshotTask(previousUnscheduledTasks, id); + + if (task && data.date !== undefined) { + const updatedTask = { ...task, ...data }; + const removeUpdatedTask = (tasks: Task[] | undefined) => + (tasks ?? []).filter((candidate) => candidate.id !== id); + + queryClient.setQueryData(key, (old) => { + const tasks = removeUpdatedTask(old); + return isInCurrentRange(updatedTask.date) + ? [...tasks, updatedTask] + : tasks; + }); + queryClient.setQueryData(unscheduledTasksQueryKey, (old) => { + const tasks = removeUpdatedTask(old); + return updatedTask.date ? tasks : [...tasks, updatedTask]; + }); + } else { + const applyUpdate = (tasks: Task[] | undefined) => + tasks?.map((candidate) => + candidate.id === id ? { ...candidate, ...data } : candidate, + ) ?? tasks; + + queryClient.setQueryData(key, applyUpdate); + queryClient.setQueryData( + unscheduledTasksQueryKey, + applyUpdate, + ); + } - return { previous }; + return { previousTask, previousUnscheduledTask, registration }; + } catch (error) { + if (previousTask && previousUnscheduledTask) { + try { + restoreTask(key, id, previousTask); + } catch { + // Continue restoring the other cache and preserve the original error. + } + try { + restoreTask(unscheduledTasksQueryKey, id, previousUnscheduledTask); + } catch { + // Preserve the original onMutate error after best-effort rollback. + } + } + try { + await releaseTaskUpdate(queryClient, registration); + } catch { + // Preserve the original onMutate error if final invalidation fails. + } + throw error; + } }, - onError: (_err, _variables, context) => { + onError: (_err, { id }, context) => { if (context) { - queryClient.setQueryData(key, context.previous); + try { + restoreTask(key, id, context.previousTask); + } catch { + // Keep the mutation error and continue restoring the other cache. + } + try { + restoreTask( + unscheduledTasksQueryKey, + id, + context.previousUnscheduledTask, + ); + } catch { + // Keep the mutation error so onSettled can release the coordinator. + } } toast.error("タスクの更新に失敗しました"); }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, + onSettled: (_data, _error, _variables, context) => + context + ? releaseTaskUpdate(queryClient, context.registration) + : undefined, }); }