diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 8e6f959..1640cde 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -31,6 +31,7 @@ import { useAuth } from "@/contexts/auth-context"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { useRefreshOnFocus } from "@/hooks/use-refresh-on-focus"; import { + isOptimisticTaskId, useCreateTask, useTasks, useUnscheduledTasks, @@ -83,8 +84,8 @@ export default function HomeScreen() { ); const scheduledTasksQuery = useTasks(year, month); const unscheduledTasksQuery = useUnscheduledTasks(); - const createTaskMutation = useCreateTask(); - const updateTaskMutation = useUpdateTask(); + const createTaskMutation = useCreateTask(year, month); + const updateTaskMutation = useUpdateTask(year, month); useRefreshOnFocus(); const scheduledTasks = scheduledTasksQuery.data ?? EMPTY_TASKS; @@ -211,6 +212,7 @@ export default function HomeScreen() { }; const handleToggleStatus = async (task: Task) => { + if (isOptimisticTaskId(task.id)) return; const newStatus = task.status === "todo" ? "done" : "todo"; try { await updateTaskMutation.mutateAsync({ @@ -223,6 +225,7 @@ export default function HomeScreen() { }; const handleOpenTask = (task: Task) => { + if (isOptimisticTaskId(task.id)) return; const taskDate = task.date ? new Date(`${task.date}T00:00:00`) : null; setSelectedDate(null); setShowUnscheduled(false); diff --git a/apps/mobile/app/task-form.tsx b/apps/mobile/app/task-form.tsx index 9575b27..18eb52f 100644 --- a/apps/mobile/app/task-form.tsx +++ b/apps/mobile/app/task-form.tsx @@ -54,9 +54,9 @@ export default function TaskFormScreen() { const initializedTaskId = useRef(null); const scheduledTasksQuery = useTasks(numericYear, numericMonth, isEditing); const unscheduledTasksQuery = useUnscheduledTasks(isEditing); - const createTaskMutation = useCreateTask(); - const updateTaskMutation = useUpdateTask(); - const deleteTaskMutation = useDeleteTask(); + const createTaskMutation = useCreateTask(numericYear, numericMonth); + const updateTaskMutation = useUpdateTask(numericYear, numericMonth); + const deleteTaskMutation = useDeleteTask(numericYear, numericMonth); const task = [ ...(scheduledTasksQuery.data ?? []), diff --git a/apps/mobile/hooks/use-tasks.test.tsx b/apps/mobile/hooks/use-tasks.test.tsx index 2a5f7a0..546c71c 100644 --- a/apps/mobile/hooks/use-tasks.test.tsx +++ b/apps/mobile/hooks/use-tasks.test.tsx @@ -10,6 +10,7 @@ import { } from "@/api/tasks"; import { getTaskDateRange, + isOptimisticTaskId, taskQueryKeys, useCreateTask, useDeleteTask, @@ -39,6 +40,22 @@ const task: Task = { updatedAt: "2026-08-06T00:00:00.000Z", }; +const unscheduledTask: Task = { + ...task, + id: "task-unscheduled", + title: "未スケジュール", + date: null, +}; + +const otherTask: Task = { + ...task, + id: "task-2", + title: "別タスク", +}; + +const { startDate, endDate } = getTaskDateRange(2026, 8); +const rangeKey = taskQueryKeys.range(startDate, endDate); + function createWrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: React.ReactNode }) { return ( @@ -50,63 +67,73 @@ function createWrapper(queryClient: QueryClient) { function createTestQueryClient() { return new QueryClient({ defaultOptions: { - mutations: { gcTime: Infinity }, + mutations: { gcTime: Infinity, retry: false }, queries: { gcTime: Infinity, retry: false }, }, }); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function setTaskCaches( + queryClient: QueryClient, + scheduled: Task[] = [], + unscheduled: Task[] = [], +) { + queryClient.setQueryData(rangeKey, scheduled); + queryClient.setQueryData(taskQueryKeys.unscheduled, unscheduled); +} + describe("mobile task queries", () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(fetchTasksRange).mockResolvedValue([]); + jest.mocked(fetchUnscheduledTasks).mockResolvedValue([]); }); - it("月の6週表示範囲から安定したquery keyを作る", async () => { + it("月の6週表示範囲と未スケジュールのquery keyで取得する", async () => { const queryClient = createTestQueryClient(); jest.mocked(fetchTasksRange).mockResolvedValue([task]); + jest.mocked(fetchUnscheduledTasks).mockResolvedValue([unscheduledTask]); - const { result, unmount } = renderHook(() => useTasks(2026, 8), { - wrapper: createWrapper(queryClient), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const { result } = renderHook( + () => ({ + scheduled: useTasks(2026, 8), + unscheduled: useUnscheduledTasks(), + }), + { wrapper: createWrapper(queryClient) }, + ); - const range = getTaskDateRange(2026, 8); - expect(range).toEqual({ + await waitFor(() => { + expect(result.current.scheduled.isSuccess).toBe(true); + expect(result.current.unscheduled.isSuccess).toBe(true); + }); + expect({ startDate, endDate }).toEqual({ startDate: "2026-07-27", endDate: "2026-09-06", }); expect(fetchTasksRange).toHaveBeenCalledWith( - range.startDate, - range.endDate, + startDate, + endDate, expect.anything(), ); - expect( - queryClient.getQueryData( - taskQueryKeys.range(range.startDate, range.endDate), - ), - ).toEqual([task]); - unmount(); - queryClient.clear(); - }); - - it("未スケジュールタスクを専用query keyで取得する", async () => { - const queryClient = createTestQueryClient(); - jest.mocked(fetchUnscheduledTasks).mockResolvedValue([task]); - - const { result, unmount } = renderHook(() => useUnscheduledTasks(), { - wrapper: createWrapper(queryClient), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([task]); - unmount(); - queryClient.clear(); + expect(queryClient.getQueryData(rangeKey)).toEqual([task]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + unscheduledTask, + ]); }); it("新規作成画面向けにtask queryを無効化できる", () => { const queryClient = createTestQueryClient(); - const { unmount } = renderHook( + renderHook( () => { useTasks(2026, 8, false); useUnscheduledTasks(false); @@ -116,58 +143,546 @@ describe("mobile task queries", () => { expect(fetchTasksRange).not.toHaveBeenCalled(); expect(fetchUnscheduledTasks).not.toHaveBeenCalled(); - unmount(); - queryClient.clear(); }); - it("作成・更新・削除の成功後にtask queryをinvalidationする", async () => { + it("楽観作成中の一時task IDを識別する", () => { + expect(isOptimisticTaskId("optimistic-123-0")).toBe(true); + expect(isOptimisticTaskId(task.id)).toBe(false); + }); +}); + +describe("mobile task optimistic mutations", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(fetchTasksRange).mockResolvedValue([]); + jest.mocked(fetchUnscheduledTasks).mockResolvedValue([]); + }); + + it.each([ + ["日付あり", { title: "作成中", date: "2026-08-20" }, rangeKey], + [ + "未スケジュール", + { title: "作成中", date: null }, + taskQueryKeys.unscheduled, + ], + ] as const)( + "%sタスクをAPI応答前に追加し、成功値で一意に置換する", + async (_label, input, targetKey) => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient); + const pending = deferred(); + jest.mocked(createTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useCreateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate(input)); + + await waitFor(() => { + const optimistic = queryClient.getQueryData(targetKey); + expect(optimistic).toHaveLength(1); + expect(optimistic?.[0]).toMatchObject(input); + expect(optimistic?.[0].id).toMatch(/^optimistic-/); + }); + + const confirmed = { ...task, ...input, id: `confirmed-${input.date}` }; + act(() => pending.resolve(confirmed)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const allTasks = [ + ...(queryClient.getQueryData(rangeKey) ?? []), + ...(queryClient.getQueryData(taskQueryKeys.unscheduled) ?? []), + ]; + expect(allTasks).toEqual([confirmed]); + expect( + allTasks.filter((value) => value.id === confirmed.id), + ).toHaveLength(1); + expect(allTasks.some((value) => value.id.startsWith("optimistic-"))).toBe( + false, + ); + }, + ); + + it("表示範囲外の作成をcacheへ先行追加しない", async () => { const queryClient = createTestQueryClient(); - const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries"); - jest.mocked(createTask).mockResolvedValue(task); - jest.mocked(updateTask).mockResolvedValue({ ...task, status: "done" }); - jest.mocked(deleteTask).mockResolvedValue(undefined); + setTaskCaches(queryClient, [task], [unscheduledTask]); + const pending = deferred(); + jest.mocked(createTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useCreateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate({ title: "翌月", date: "2026-10-01" })); + await waitFor(() => expect(createTask).toHaveBeenCalled()); - const create = renderHook(() => useCreateTask(), { + expect(queryClient.getQueryData(rangeKey)).toEqual([task]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + unscheduledTask, + ]); + act(() => pending.resolve({ ...task, id: "outside", date: "2026-10-01" })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); + + it("作成失敗時に一時taskだけを除去し、未取得cacheを未取得へ戻す", async () => { + const queryClient = createTestQueryClient(); + const pending = deferred(); + jest.mocked(createTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useCreateTask(2026, 8), { wrapper: createWrapper(queryClient), }); - const update = renderHook(() => useUpdateTask(), { + + act(() => result.current.mutate({ title: "失敗", date: null })); + await waitFor(() => + expect( + queryClient.getQueryData(taskQueryKeys.unscheduled), + ).toHaveLength(1), + ); + act(() => pending.reject(new Error("create failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toBeUndefined(); + }); + + it("所属が変わらない更新をAPI応答前に反映する", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task, otherTask], [unscheduledTask]); + const pending = deferred(); + jest.mocked(updateTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useUpdateTask(2026, 8), { wrapper: createWrapper(queryClient), }); - const remove = renderHook(() => useDeleteTask(), { + const data = { + title: "更新済み", + description: "説明", + status: "done" as const, + categoryId: "category-1", + }; + + act(() => result.current.mutate({ id: task.id, data })); + await waitFor(() => + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...task, ...data }, + otherTask, + ]), + ); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + unscheduledTask, + ]); + + act(() => pending.resolve({ ...task, ...data })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); + + it.each([ + [ + "日付ありから未スケジュール", + task, + { date: null }, + [], + [{ ...task, date: null }], + ], + [ + "未スケジュールから表示範囲内", + unscheduledTask, + { date: "2026-08-20" }, + [{ ...unscheduledTask, date: "2026-08-20" }], + [], + ], + ["表示範囲内から範囲外", task, { date: "2026-10-01" }, [], []], + ] as const)( + "%sへAPI応答前に正しく移動する", + async (_label, source, data, expectedRange, expectedUnscheduled) => { + const queryClient = createTestQueryClient(); + setTaskCaches( + queryClient, + source.date === null ? [] : [source], + source.date === null ? [source] : [], + ); + const pending = deferred(); + jest.mocked(updateTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate({ id: source.id, data })); + await waitFor(() => { + expect(queryClient.getQueryData(rangeKey)).toEqual(expectedRange); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual( + expectedUnscheduled, + ); + }); + + act(() => pending.resolve({ ...source, ...data })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }, + ); + + it("更新失敗時に対象taskだけを戻し、別taskの並行変更を失わない", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task, otherTask]); + const pending = deferred(); + jest.mocked(updateTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useUpdateTask(2026, 8), { wrapper: createWrapper(queryClient), }); - await act(async () => { - await create.result.current.mutateAsync({ title: task.title }); - await update.result.current.mutateAsync({ + act(() => result.current.mutate({ id: task.id, data: { date: null } })); + await waitFor(() => + expect(queryClient.getQueryData(rangeKey)).toEqual([otherTask]), + ); + const concurrentlyUpdated = { ...otherTask, title: "並行更新" }; + queryClient.setQueryData(rangeKey, [concurrentlyUpdated]); + + act(() => pending.reject(new Error("update failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + task, + concurrentlyUpdated, + ]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([]); + }); + + it("異なるtaskの更新を並行実行し、一方のrollback後も他方を維持する", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task, otherTask]); + const first = deferred(); + const second = deferred(); + jest + .mocked(updateTask) + .mockImplementation((id) => + id === task.id ? first.promise : second.promise, + ); + const firstHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const secondHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + firstHook.result.current.mutate({ id: task.id, data: { date: null } }); + secondHook.result.current.mutate({ + id: otherTask.id, + data: { title: "成功予定" }, + }); + }); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(2)); + + act(() => first.reject(new Error("first failed"))); + await waitFor(() => expect(firstHook.result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + task, + { ...otherTask, title: "成功予定" }, + ]); + + act(() => second.resolve({ ...otherTask, title: "成功予定" })); + await waitFor(() => expect(secondHook.result.current.isSuccess).toBe(true)); + }); + + it("同一taskの連続更新を直列化し、先行失敗後に後続更新を反映する", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task]); + const first = deferred(); + const second = deferred(); + jest + .mocked(updateTask) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const firstHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const secondHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + firstHook.result.current.mutate({ id: task.id, data: { date: null } }); + secondHook.result.current.mutate({ + id: task.id, + data: { title: "後続更新" }, + }); + }); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1)); + + act(() => first.reject(new Error("first failed"))); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(2)); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...task, title: "後続更新" }, + ]); + + act(() => second.resolve({ ...task, title: "後続更新" })); + await waitFor(() => expect(secondHook.result.current.isSuccess).toBe(true)); + }); + + it("同一taskの先行成功後に後続失敗した場合は先行の確定状態へ戻す", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task]); + const first = deferred(); + const second = deferred(); + jest + .mocked(updateTask) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const firstHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const secondHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + firstHook.result.current.mutate({ id: task.id, data: { date: null } }); + secondHook.result.current.mutate({ + id: task.id, + data: { title: "失敗予定" }, + }); + }); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1)); + + const firstConfirmed = { ...task, date: null }; + act(() => first.resolve(firstConfirmed)); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(2)); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + { ...firstConfirmed, title: "失敗予定" }, + ]); + + act(() => second.reject(new Error("second failed"))); + await waitFor(() => expect(secondHook.result.current.isError).toBe(true)); + expect(queryClient.getQueryData(rangeKey)).toEqual([]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + firstConfirmed, + ]); + }); + + it("同一taskの連続更新が両方成功した場合は最後の確定状態を保持する", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task]); + const first = deferred(); + const second = deferred(); + jest + .mocked(updateTask) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const firstHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const secondHook = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + firstHook.result.current.mutate({ + id: task.id, + data: { title: "先行成功" }, + }); + secondHook.result.current.mutate({ id: task.id, data: { status: "done" }, }); - await remove.result.current.mutateAsync(task.id); }); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1)); + const firstConfirmed = { ...task, title: "先行成功" }; + act(() => first.resolve(firstConfirmed)); + await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(2)); + const finalConfirmed = { ...firstConfirmed, status: "done" as const }; + act(() => second.resolve(finalConfirmed)); + await waitFor(() => expect(secondHook.result.current.isSuccess).toBe(true)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([finalConfirmed]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([]); + }); + + it("更新失敗時に未取得だった反対側cacheを未取得状態へ戻す", async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(rangeKey, [task]); + const pending = deferred(); + jest.mocked(updateTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate({ id: task.id, data: { date: null } })); + await waitFor(() => + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toEqual([ + { ...task, date: null }, + ]), + ); + act(() => pending.reject(new Error("update failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([task]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toBeUndefined(); + }); + + it("削除失敗時に未取得だった反対側cacheを未取得状態へ戻す", async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(rangeKey, [task]); + const pending = deferred(); + jest.mocked(deleteTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useDeleteTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate(task.id)); + await waitFor(() => expect(queryClient.getQueryData(rangeKey)).toEqual([])); + act(() => pending.reject(new Error("delete failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([task]); + expect(queryClient.getQueryData(taskQueryKeys.unscheduled)).toBeUndefined(); + }); + + it.each([task, unscheduledTask])( + "削除を保持cacheへ即時反映し、失敗時に戻す: $id", + async (deleted) => { + const queryClient = createTestQueryClient(); + setTaskCaches( + queryClient, + deleted.date === null ? [otherTask] : [deleted, otherTask], + deleted.date === null ? [deleted] : [unscheduledTask], + ); + const pending = deferred(); + jest.mocked(deleteTask).mockReturnValue(pending.promise); + const { result } = renderHook(() => useDeleteTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => result.current.mutate(deleted.id)); + await waitFor(() => { + expect( + queryClient + .getQueryData(rangeKey) + ?.some(({ id }) => id === deleted.id), + ).toBe(false); + expect( + queryClient + .getQueryData(taskQueryKeys.unscheduled) + ?.some(({ id }) => id === deleted.id), + ).toBe(false); + }); + + act(() => pending.reject(new Error("delete failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + const restored = [ + ...(queryClient.getQueryData(rangeKey) ?? []), + ...(queryClient.getQueryData(taskQueryKeys.unscheduled) ?? []), + ]; + expect(restored.filter(({ id }) => id === deleted.id)).toEqual([deleted]); + }, + ); + + it("進行中mutationがある間は再取得せず、全完了後に一度だけ同期する", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task, otherTask]); + const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries"); + const first = deferred(); + const second = deferred(); + jest.mocked(updateTask).mockReturnValue(first.promise); + jest.mocked(deleteTask).mockReturnValue(second.promise); + const update = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + const remove = renderHook(() => useDeleteTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + act(() => { + update.result.current.mutate({ + id: task.id, + data: { title: "更新中" }, + }); + remove.result.current.mutate(otherTask.id); + }); await waitFor(() => { - expect(create.result.current.isSuccess).toBe(true); - expect(update.result.current.isSuccess).toBe(true); - expect(remove.result.current.isSuccess).toBe(true); + expect(updateTask).toHaveBeenCalled(); + expect(deleteTask).toHaveBeenCalled(); }); - expect(createTask).toHaveBeenCalledWith({ title: task.title }); - expect(updateTask).toHaveBeenCalledWith(task.id, { status: "done" }); - expect(deleteTask).toHaveBeenCalledWith(task.id); - expect(invalidateQueries).toHaveBeenCalledTimes(3); - expect(invalidateQueries).toHaveBeenNthCalledWith(1, { + act(() => first.resolve({ ...task, title: "更新中" })); + await waitFor(() => expect(update.result.current.isSuccess).toBe(true)); + expect(invalidateQueries).not.toHaveBeenCalled(); + + act(() => second.resolve()); + await waitFor(() => expect(remove.result.current.isSuccess).toBe(true)); + expect(invalidateQueries).toHaveBeenCalledTimes(1); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: taskQueryKeys.all, }); - expect(invalidateQueries).toHaveBeenNthCalledWith(2, { - queryKey: taskQueryKeys.all, + }); + + it("onMutate例外でもlockを解放し、同一taskの後続更新を実行できる", async () => { + const queryClient = createTestQueryClient(); + setTaskCaches(queryClient, [task]); + const originalCancelQueries = queryClient.cancelQueries.bind(queryClient); + jest + .spyOn(queryClient, "cancelQueries") + .mockRejectedValueOnce(new Error("cancel failed")) + .mockImplementation((filters, options) => + originalCancelQueries(filters, options), + ); + jest.mocked(updateTask).mockResolvedValue({ ...task, title: "回復" }); + const first = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), }); - expect(invalidateQueries).toHaveBeenNthCalledWith(3, { - queryKey: taskQueryKeys.all, + const second = renderHook(() => useUpdateTask(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + await expect( + first.result.current.mutateAsync({ + id: task.id, + data: { title: "失敗" }, + }), + ).rejects.toThrow("cancel failed"); + + await act(async () => { + await second.result.current.mutateAsync({ + id: task.id, + data: { title: "回復" }, + }); + }); + expect(updateTask).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(rangeKey)).toEqual([ + { ...task, title: "回復" }, + ]); + }); + + it("成功後の再取得で一時taskを残さずサーバー確定値に同期する", async () => { + const queryClient = createTestQueryClient(); + const confirmed = { ...task, id: "server-task", title: "確定値" }; + jest + .mocked(fetchTasksRange) + .mockResolvedValueOnce([]) + .mockResolvedValue([confirmed]); + jest.mocked(fetchUnscheduledTasks).mockResolvedValue([]); + jest.mocked(createTask).mockResolvedValue(confirmed); + const { result } = renderHook( + () => ({ + create: useCreateTask(2026, 8), + scheduled: useTasks(2026, 8), + unscheduled: useUnscheduledTasks(), + }), + { wrapper: createWrapper(queryClient) }, + ); + await waitFor(() => { + expect(result.current.scheduled.isSuccess).toBe(true); + expect(result.current.unscheduled.isSuccess).toBe(true); }); - create.unmount(); - update.unmount(); - remove.unmount(); - queryClient.clear(); + + await act(async () => { + await result.current.create.mutateAsync({ + title: confirmed.title, + date: confirmed.date, + }); + }); + await waitFor(() => expect(fetchTasksRange).toHaveBeenCalledTimes(2)); + + expect(queryClient.getQueryData(rangeKey)).toEqual([confirmed]); + expect( + queryClient + .getQueryData(rangeKey) + ?.filter(({ id }) => id === confirmed.id), + ).toHaveLength(1); }); }); diff --git a/apps/mobile/hooks/use-tasks.ts b/apps/mobile/hooks/use-tasks.ts index dc16ff1..eb081b8 100644 --- a/apps/mobile/hooks/use-tasks.ts +++ b/apps/mobile/hooks/use-tasks.ts @@ -1,6 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { getCalendarDateRange } from "@tascal/shared/calendar"; import type { + Task, TaskCreateInput, TaskUpdateInput, } from "@tascal/shared/api-contract"; @@ -20,6 +26,183 @@ export const taskQueryKeys = { unscheduled: ["tasks", "unscheduled"] as const, }; +type TaskSnapshot = { + hadData: boolean; + index: number; + task: Task | undefined; +}; + +type MutationCoordinator = { + activeCount: number; + locks: Map>; +}; + +type MutationRegistration = { + coordinator: MutationCoordinator; + lockKey: string; + releaseLock: () => void; + released: boolean; + tail: Promise; +}; + +const mutationCoordinators = new WeakMap(); +const OPTIMISTIC_TASK_ID_PREFIX = "optimistic-"; +let optimisticTaskSequence = 0; + +export function isOptimisticTaskId(id: string) { + return id.startsWith(OPTIMISTIC_TASK_ID_PREFIX); +} + +function registerMutation( + queryClient: QueryClient, + lockKey: string, +): { previous: Promise; registration: MutationRegistration } { + const coordinator = mutationCoordinators.get(queryClient) ?? { + activeCount: 0, + locks: new Map>(), + }; + coordinator.activeCount += 1; + mutationCoordinators.set(queryClient, coordinator); + + const previous = coordinator.locks.get(lockKey) ?? Promise.resolve(); + let releaseLock!: () => void; + const current = new Promise((resolve) => { + releaseLock = resolve; + }); + const tail = previous.then(() => current); + coordinator.locks.set(lockKey, tail); + + return { + previous, + registration: { + coordinator, + lockKey, + releaseLock, + released: false, + tail, + }, + }; +} + +function releaseMutation( + queryClient: QueryClient, + registration: MutationRegistration, +) { + if (registration.released) return; + registration.released = true; + registration.releaseLock(); + + const { coordinator, lockKey, tail } = registration; + if (coordinator.locks.get(lockKey) === tail) { + coordinator.locks.delete(lockKey); + } + coordinator.activeCount -= 1; + + if (coordinator.activeCount === 0) { + mutationCoordinators.delete(queryClient); + return queryClient.invalidateQueries({ queryKey: taskQueryKeys.all }); + } +} + +function snapshotTask(tasks: Task[] | undefined, id: string): TaskSnapshot { + const index = tasks?.findIndex((task) => task.id === id) ?? -1; + return { + hadData: tasks !== undefined, + index, + task: index >= 0 ? tasks?.[index] : undefined, + }; +} + +function restoreTask( + queryClient: QueryClient, + 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 }); + } +} + +function restoreTaskCaches( + queryClient: QueryClient, + rangeKey: readonly string[], + id: string, + rangeSnapshot: TaskSnapshot, + unscheduledSnapshot: TaskSnapshot, +) { + try { + restoreTask(queryClient, rangeKey, id, rangeSnapshot); + } catch { + // Preserve the mutation error and continue restoring the other cache. + } + try { + restoreTask( + queryClient, + taskQueryKeys.unscheduled, + id, + unscheduledSnapshot, + ); + } catch { + // Rollback is best effort so the original mutation error is retained. + } +} + +function removeTask(tasks: Task[] | undefined, id: string) { + return (tasks ?? []).filter((task) => task.id !== id); +} + +function placeTask( + queryClient: QueryClient, + rangeKey: readonly string[], + isInCurrentRange: (date: string | null) => boolean, + task: Task, + replacedId = task.id, +) { + const placeInCache = (current: Task[] | undefined, belongs: boolean) => { + const currentTasks = current ?? []; + const replacedIndex = currentTasks.findIndex( + (candidate) => candidate.id === replacedId || candidate.id === task.id, + ); + const tasks = currentTasks.filter( + (candidate) => candidate.id !== replacedId && candidate.id !== task.id, + ); + if (!belongs) return tasks; + + const index = replacedIndex < 0 ? tasks.length : replacedIndex; + return [...tasks.slice(0, index), task, ...tasks.slice(index)]; + }; + + queryClient.setQueryData(rangeKey, (current) => { + return placeInCache(current, isInCurrentRange(task.date)); + }); + queryClient.setQueryData(taskQueryKeys.unscheduled, (current) => { + return placeInCache(current, task.date === null); + }); +} + +function useTaskMutationScope(year: number, month: number) { + const queryClient = useQueryClient(); + const { startDate, endDate } = getCalendarDateRange(year, month); + return { + queryClient, + rangeKey: taskQueryKeys.range(startDate, endDate), + isInCurrentRange: (date: string | null) => + date !== null && startDate <= date && date <= endDate, + }; +} + export { getCalendarDateRange as getTaskDateRange }; export function useTasks(year: number, month: number, enabled = true) { @@ -39,32 +222,236 @@ export function useUnscheduledTasks(enabled = true) { }); } -function useInvalidateTasks() { - const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: taskQueryKeys.all }); -} +export function useCreateTask(year: number, month: number) { + const { queryClient, rangeKey, isInCurrentRange } = useTaskMutationScope( + year, + month, + ); -export function useCreateTask() { - const invalidateTasks = useInvalidateTasks(); return useMutation({ mutationFn: (data: TaskCreateInput) => createTask(data), - onSuccess: invalidateTasks, + onMutate: async (data) => { + const optimisticId = `${OPTIMISTIC_TASK_ID_PREFIX}${Date.now()}-${optimisticTaskSequence++}`; + const { previous, registration } = registerMutation( + queryClient, + optimisticId, + ); + const optimisticDate = data.date ?? null; + const targetKey = + optimisticDate === null ? taskQueryKeys.unscheduled : rangeKey; + let snapshot: TaskSnapshot | undefined; + + try { + await previous; + await queryClient.cancelQueries({ queryKey: targetKey }); + snapshot = snapshotTask( + queryClient.getQueryData(targetKey), + optimisticId, + ); + + const now = new Date().toISOString(); + const optimisticTask: Task = { + id: optimisticId, + userId: "", + title: data.title, + description: data.description ?? null, + date: optimisticDate, + status: data.status ?? "todo", + categoryId: data.categoryId ?? null, + createdAt: now, + updatedAt: now, + }; + if ( + optimisticTask.date === null || + isInCurrentRange(optimisticTask.date) + ) { + queryClient.setQueryData(targetKey, (current) => [ + ...(current ?? []), + optimisticTask, + ]); + } + + return { optimisticId, registration, snapshot, targetKey }; + } catch (error) { + if (snapshot) { + try { + restoreTask(queryClient, targetKey, optimisticId, snapshot); + } catch { + // Preserve the original onMutate error. + } + } + try { + await releaseMutation(queryClient, registration); + } catch { + // Preserve the original onMutate error if invalidation fails. + } + throw error; + } + }, + onSuccess: (task, _variables, context) => { + if (context) { + placeTask( + queryClient, + rangeKey, + isInCurrentRange, + task, + context.optimisticId, + ); + } + }, + onError: (_error, _variables, context) => { + if (context) { + restoreTask( + queryClient, + context.targetKey, + context.optimisticId, + context.snapshot, + ); + } + }, + onSettled: (_data, _error, _variables, context) => + context ? releaseMutation(queryClient, context.registration) : undefined, }); } -export function useUpdateTask() { - const invalidateTasks = useInvalidateTasks(); +export function useUpdateTask(year: number, month: number) { + const { queryClient, rangeKey, isInCurrentRange } = useTaskMutationScope( + year, + month, + ); + return useMutation({ mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => updateTask(id, data), - onSuccess: invalidateTasks, + onMutate: async ({ id, data }) => { + const { previous, registration } = registerMutation(queryClient, id); + let rangeSnapshot: TaskSnapshot | undefined; + let unscheduledSnapshot: TaskSnapshot | undefined; + + try { + await previous; + await Promise.all([ + queryClient.cancelQueries({ queryKey: rangeKey }), + queryClient.cancelQueries({ queryKey: taskQueryKeys.unscheduled }), + ]); + const rangeTasks = queryClient.getQueryData(rangeKey); + const unscheduledTasks = queryClient.getQueryData( + taskQueryKeys.unscheduled, + ); + rangeSnapshot = snapshotTask(rangeTasks, id); + unscheduledSnapshot = snapshotTask(unscheduledTasks, id); + const task = [...(rangeTasks ?? []), ...(unscheduledTasks ?? [])].find( + (candidate) => candidate.id === id, + ); + + if (task) { + placeTask(queryClient, rangeKey, isInCurrentRange, { + ...task, + ...data, + }); + } + + return { rangeSnapshot, registration, unscheduledSnapshot }; + } catch (error) { + if (rangeSnapshot && unscheduledSnapshot) { + restoreTaskCaches( + queryClient, + rangeKey, + id, + rangeSnapshot, + unscheduledSnapshot, + ); + } + try { + await releaseMutation(queryClient, registration); + } catch { + // Preserve the original onMutate error if invalidation fails. + } + throw error; + } + }, + onSuccess: (task) => { + placeTask(queryClient, rangeKey, isInCurrentRange, task); + }, + onError: (_error, { id }, context) => { + if (context) { + restoreTaskCaches( + queryClient, + rangeKey, + id, + context.rangeSnapshot, + context.unscheduledSnapshot, + ); + } + }, + onSettled: (_data, _error, _variables, context) => + context ? releaseMutation(queryClient, context.registration) : undefined, }); } -export function useDeleteTask() { - const invalidateTasks = useInvalidateTasks(); +export function useDeleteTask(year: number, month: number) { + const { queryClient, rangeKey } = useTaskMutationScope(year, month); + return useMutation({ mutationFn: (id: string) => deleteTask(id), - onSuccess: invalidateTasks, + onMutate: async (id) => { + const { previous, registration } = registerMutation(queryClient, id); + let rangeSnapshot: TaskSnapshot | undefined; + let unscheduledSnapshot: TaskSnapshot | undefined; + + try { + await previous; + await Promise.all([ + queryClient.cancelQueries({ queryKey: rangeKey }), + queryClient.cancelQueries({ queryKey: taskQueryKeys.unscheduled }), + ]); + rangeSnapshot = snapshotTask( + queryClient.getQueryData(rangeKey), + id, + ); + unscheduledSnapshot = snapshotTask( + queryClient.getQueryData(taskQueryKeys.unscheduled), + id, + ); + queryClient.setQueryData(rangeKey, (current) => + current ? removeTask(current, id) : current, + ); + queryClient.setQueryData( + taskQueryKeys.unscheduled, + (current) => (current ? removeTask(current, id) : current), + ); + + return { rangeSnapshot, registration, unscheduledSnapshot }; + } catch (error) { + if (rangeSnapshot && unscheduledSnapshot) { + restoreTaskCaches( + queryClient, + rangeKey, + id, + rangeSnapshot, + unscheduledSnapshot, + ); + } + try { + await releaseMutation(queryClient, registration); + } catch { + // Preserve the original onMutate error if invalidation fails. + } + throw error; + } + }, + onError: (_error, id, context) => { + if (context) { + restoreTaskCaches( + queryClient, + rangeKey, + id, + context.rangeSnapshot, + context.unscheduledSnapshot, + ); + } + }, + onSettled: (_data, _error, _variables, context) => + context ? releaseMutation(queryClient, context.registration) : undefined, }); }