From c0851a244f93824679eed14df54ce9dea17601bc Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 12:56:58 +0900 Subject: [PATCH 1/4] fix(web): synchronize optimistic task caches --- apps/web/src/hooks/useTasks.test.tsx | 170 +++++++++++++++++++++++++++ apps/web/src/hooks/useTasks.ts | 48 ++++++-- 2 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/hooks/useTasks.test.tsx diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx new file mode 100644 index 0000000..1d06e4f --- /dev/null +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -0,0 +1,170 @@ +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 { useUpdateTask } from "./useTasks"; + +const mockUpdateTask = vi.fn(); + +vi.mock("../api/tasks", () => ({ + fetchTasks: vi.fn(), + fetchUnscheduledTasks: vi.fn(), + 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 reject!: (error: Error) => void; + const promise = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + mockUpdateTask.mockReturnValue(promise); + return { reject }; +} + +describe("useUpdateTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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("日付を変えない更新では所属を維持して対象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..149c9a1 100644 --- a/apps/web/src/hooks/useTasks.ts +++ b/apps/web/src/hooks/useTasks.ts @@ -86,20 +86,52 @@ export function useUpdateTask(year: number, month: number) { mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => updateTask(id, data), onMutate: async ({ id, data }) => { - await queryClient.cancelQueries({ queryKey: key }); - const previous = queryClient.getQueryData(key); + await Promise.all([ + queryClient.cancelQueries({ queryKey: key }), + queryClient.cancelQueries({ queryKey: unscheduledTasksQueryKey }), + ]); - queryClient.setQueryData(key, (old) => - (old ?? []).map((task) => - task.id === id ? { ...task, ...data } : task, - ), + const previousTasks = queryClient.getQueryData(key); + const previousUnscheduledTasks = queryClient.getQueryData( + unscheduledTasksQueryKey, ); + const task = [ + ...(previousTasks ?? []), + ...(previousUnscheduledTasks ?? []), + ].find((candidate) => candidate.id === 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 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, + ); + + queryClient.setQueryData(key, applyUpdate); + queryClient.setQueryData(unscheduledTasksQueryKey, applyUpdate); + } - return { previous }; + return { previousTasks, previousUnscheduledTasks }; }, onError: (_err, _variables, context) => { if (context) { - queryClient.setQueryData(key, context.previous); + queryClient.setQueryData(key, context.previousTasks ?? []); + queryClient.setQueryData( + unscheduledTasksQueryKey, + context.previousUnscheduledTasks ?? [], + ); } toast.error("タスクの更新に失敗しました"); }, From 42fa4e1641b293658748b23a3b0345ab9b3b47c4 Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 13:06:20 +0900 Subject: [PATCH 2/4] fix(web): harden optimistic task cache updates --- apps/web/src/hooks/useTasks.test.tsx | 253 ++++++++++++++++++++++++++- apps/web/src/hooks/useTasks.ts | 105 +++++++++-- 2 files changed, 337 insertions(+), 21 deletions(-) diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx index 1d06e4f..e016e70 100644 --- a/apps/web/src/hooks/useTasks.test.tsx +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -4,13 +4,16 @@ 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 { useUpdateTask } from "./useTasks"; +import { useTasks, useUnscheduledTasks, useUpdateTask } from "./useTasks"; +const mockFetchTasks = vi.fn(); +const mockFetchUnscheduledTasks = vi.fn(); const mockUpdateTask = vi.fn(); vi.mock("../api/tasks", () => ({ - fetchTasks: vi.fn(), - fetchUnscheduledTasks: vi.fn(), + 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(), @@ -61,17 +64,31 @@ function createWrapper(queryClient: QueryClient) { } function createPendingUpdate() { + let resolve!: (task: Task) => void; let reject!: (error: Error) => void; - const promise = new Promise((_resolve, rejectPromise) => { + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; reject = rejectPromise; }); mockUpdateTask.mockReturnValue(promise); - return { reject }; + 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 () => { @@ -137,6 +154,232 @@ describe("useUpdateTask", () => { 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のAPI mutationを直列化し、先行失敗で後続optimistic更新を戻さない", 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([]); + expect(queryClient.getQueryData(unscheduledKey)).toEqual([ + { ...scheduledTask, title: "後続更新", date: null }, + ]); + + secondUpdate.resolve({ ...scheduledTask, title: "後続更新" }); + await waitFor(() => expect(second.result.current.isSuccess).toBe(true)); + }); + + 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("成功時は購読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]); diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts index 149c9a1..e36c502 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,8 @@ function tasksQueryKey(startDate: string, endDate: string) { } const unscheduledTasksQueryKey = ["tasks", "unscheduled"] as const; +const taskUpdateVersions = new WeakMap>(); +const taskUpdateQueues = new WeakMap>>(); export function useTasks(year: number, month: number) { const { startDate, endDate } = getCalendarDateRange(year, month); @@ -82,9 +85,35 @@ 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, + }; + }; + return useMutation({ - mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => - updateTask(id, data), + mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => { + const queues = + taskUpdateQueues.get(queryClient) ?? new Map>(); + const previous = queues.get(id) ?? Promise.resolve(); + const operation = previous.then(() => updateTask(id, data)); + const settled = operation.then( + () => undefined, + () => undefined, + ); + queues.set(id, settled); + taskUpdateQueues.set(queryClient, queues); + void settled.finally(() => { + if (queues.get(id) === settled) queues.delete(id); + }); + return operation; + }, onMutate: async ({ id, data }) => { await Promise.all([ queryClient.cancelQueries({ queryKey: key }), @@ -99,6 +128,16 @@ export function useUpdateTask(year: number, month: number) { ...(previousTasks ?? []), ...(previousUnscheduledTasks ?? []), ].find((candidate) => candidate.id === id); + const previousTask = snapshotTask(previousTasks, id); + const previousUnscheduledTask = snapshotTask( + previousUnscheduledTasks, + id, + ); + const version = Symbol(id); + const versions = + taskUpdateVersions.get(queryClient) ?? new Map(); + versions.set(id, version); + taskUpdateVersions.set(queryClient, versions); if (task && data.date !== undefined) { const updatedTask = { ...task, ...data }; @@ -107,7 +146,9 @@ export function useUpdateTask(year: number, month: number) { queryClient.setQueryData(key, (old) => { const tasks = removeUpdatedTask(old); - return updatedTask.date ? [...tasks, updatedTask] : tasks; + return isInCurrentRange(updatedTask.date) + ? [...tasks, updatedTask] + : tasks; }); queryClient.setQueryData(unscheduledTasksQueryKey, (old) => { const tasks = removeUpdatedTask(old); @@ -115,29 +156,61 @@ export function useUpdateTask(year: number, month: number) { }); } else { const applyUpdate = (tasks: Task[] | undefined) => - (tasks ?? []).map((candidate) => + tasks?.map((candidate) => candidate.id === id ? { ...candidate, ...data } : candidate, - ); + ) ?? tasks; queryClient.setQueryData(key, applyUpdate); queryClient.setQueryData(unscheduledTasksQueryKey, applyUpdate); } - return { previousTasks, previousUnscheduledTasks }; + return { previousTask, previousUnscheduledTask, version }; }, - onError: (_err, _variables, context) => { - if (context) { - queryClient.setQueryData(key, context.previousTasks ?? []); - queryClient.setQueryData( - unscheduledTasksQueryKey, - context.previousUnscheduledTasks ?? [], - ); + onError: (_err, { id }, context) => { + if ( + context && + taskUpdateVersions.get(queryClient)?.get(id) === context.version + ) { + const restoreTask = ( + queryKey: readonly string[], + snapshot: { + hadData: boolean; + index: number; + task: Task | undefined; + }, + ) => { + 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 }); + } + }; + + restoreTask(key, context.previousTask); + restoreTask(unscheduledTasksQueryKey, context.previousUnscheduledTask); } toast.error("タスクの更新に失敗しました"); }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, + onSettled: (_data, _error, { id }, context) => + queryClient.invalidateQueries({ queryKey: ["tasks"] }).finally(() => { + const versions = taskUpdateVersions.get(queryClient); + if (context && versions?.get(id) === context.version) { + versions.delete(id); + } + }), }); } From fd599fca070daa20dda9ece143f77192b66484e5 Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 13:13:02 +0900 Subject: [PATCH 3/4] fix(web): coordinate concurrent task updates --- apps/web/src/hooks/useTasks.test.tsx | 170 ++++++++++++++++++++++++++- apps/web/src/hooks/useTasks.ts | 78 ++++++------ 2 files changed, 209 insertions(+), 39 deletions(-) diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx index e016e70..ce053fb 100644 --- a/apps/web/src/hooks/useTasks.test.tsx +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -263,7 +263,7 @@ describe("useUpdateTask", () => { await waitFor(() => expect(second.result.current.isSuccess).toBe(true)); }); - it("同一IDのAPI mutationを直列化し、先行失敗で後続optimistic更新を戻さない", async () => { + it("同一IDの先行移動失敗後に後続更新を正しい所属へ反映する", async () => { const queryClient = createQueryClient(); queryClient.setQueryData(rangeKey, [scheduledTask]); queryClient.setQueryData(unscheduledKey, []); @@ -294,13 +294,54 @@ describe("useUpdateTask", () => { firstUpdate.reject(new Error("first update failed")); await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2)); - expect(queryClient.getQueryData(rangeKey)).toEqual([]); - expect(queryClient.getQueryData(unscheduledKey)).toEqual([ - { ...scheduledTask, title: "後続更新", date: null }, + 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 () => { @@ -331,6 +372,127 @@ describe("useUpdateTask", () => { expect(queryClient.getQueryData(unscheduledKey)).toBeUndefined(); }); + 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, diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts index e36c502..235fe57 100644 --- a/apps/web/src/hooks/useTasks.ts +++ b/apps/web/src/hooks/useTasks.ts @@ -20,8 +20,14 @@ function tasksQueryKey(startDate: string, endDate: string) { } const unscheduledTasksQueryKey = ["tasks", "unscheduled"] as const; -const taskUpdateVersions = new WeakMap>(); -const taskUpdateQueues = new WeakMap>>(); +type TaskUpdateCoordinator = { + activeCount: number; + locks: Map>; +}; +const taskUpdateCoordinators = new WeakMap< + QueryClient, + TaskUpdateCoordinator +>(); export function useTasks(year: number, month: number) { const { startDate, endDate } = getCalendarDateRange(year, month); @@ -98,23 +104,25 @@ export function useUpdateTask(year: number, month: number) { }; return useMutation({ - mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => { - const queues = - taskUpdateQueues.get(queryClient) ?? new Map>(); - const previous = queues.get(id) ?? Promise.resolve(); - const operation = previous.then(() => updateTask(id, data)); - const settled = operation.then( - () => undefined, - () => undefined, - ); - queues.set(id, settled); - taskUpdateQueues.set(queryClient, queues); - void settled.finally(() => { - if (queues.get(id) === settled) queues.delete(id); - }); - return operation; - }, + mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) => + updateTask(id, data), onMutate: async ({ id, data }) => { + const coordinator = taskUpdateCoordinators.get(queryClient) ?? { + activeCount: 0, + locks: new Map>(), + }; + coordinator.activeCount += 1; + taskUpdateCoordinators.set(queryClient, coordinator); + + 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); + await previous; + await Promise.all([ queryClient.cancelQueries({ queryKey: key }), queryClient.cancelQueries({ queryKey: unscheduledTasksQueryKey }), @@ -133,11 +141,6 @@ export function useUpdateTask(year: number, month: number) { previousUnscheduledTasks, id, ); - const version = Symbol(id); - const versions = - taskUpdateVersions.get(queryClient) ?? new Map(); - versions.set(id, version); - taskUpdateVersions.set(queryClient, versions); if (task && data.date !== undefined) { const updatedTask = { ...task, ...data }; @@ -164,13 +167,10 @@ export function useUpdateTask(year: number, month: number) { queryClient.setQueryData(unscheduledTasksQueryKey, applyUpdate); } - return { previousTask, previousUnscheduledTask, version }; + return { previousTask, previousUnscheduledTask, release, tail }; }, onError: (_err, { id }, context) => { - if ( - context && - taskUpdateVersions.get(queryClient)?.get(id) === context.version - ) { + if (context) { const restoreTask = ( queryKey: readonly string[], snapshot: { @@ -204,13 +204,21 @@ export function useUpdateTask(year: number, month: number) { } toast.error("タスクの更新に失敗しました"); }, - onSettled: (_data, _error, { id }, context) => - queryClient.invalidateQueries({ queryKey: ["tasks"] }).finally(() => { - const versions = taskUpdateVersions.get(queryClient); - if (context && versions?.get(id) === context.version) { - versions.delete(id); - } - }), + onSettled: (_data, _error, { id }, context) => { + const coordinator = taskUpdateCoordinators.get(queryClient); + if (!context || !coordinator) return; + + context.release(); + if (coordinator.locks.get(id) === context.tail) { + coordinator.locks.delete(id); + } + coordinator.activeCount -= 1; + + if (coordinator.activeCount === 0) { + taskUpdateCoordinators.delete(queryClient); + return queryClient.invalidateQueries({ queryKey: ["tasks"] }); + } + }, }); } From b570815f941774b55937d2f4af2cf774ce211e8e Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 13:19:31 +0900 Subject: [PATCH 4/4] fix(web): release task update locks on mutation errors --- apps/web/src/hooks/useTasks.test.tsx | 50 ++++++ apps/web/src/hooks/useTasks.ts | 228 +++++++++++++++++---------- 2 files changed, 192 insertions(+), 86 deletions(-) diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx index ce053fb..24e9ea2 100644 --- a/apps/web/src/hooks/useTasks.test.tsx +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -372,6 +372,56 @@ describe("useUpdateTask", () => { 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, diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts index 235fe57..98b8f8e 100644 --- a/apps/web/src/hooks/useTasks.ts +++ b/apps/web/src/hooks/useTasks.ts @@ -24,11 +24,43 @@ 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); @@ -103,6 +135,27 @@ export function useUpdateTask(year: number, month: number) { }; }; + 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), @@ -121,104 +174,107 @@ export function useUpdateTask(year: number, month: number) { }); const tail = previous.then(() => current); coordinator.locks.set(id, tail); - 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); - const previousTask = snapshotTask(previousTasks, id); - const previousUnscheduledTask = snapshotTask( - previousUnscheduledTasks, + const registration: TaskUpdateRegistration = { + coordinator, id, - ); + releaseLock: release, + released: false, + tail, + }; + let previousTask: TaskSnapshot | undefined; + let previousUnscheduledTask: TaskSnapshot | undefined; - if (task && data.date !== undefined) { - const updatedTask = { ...task, ...data }; - const removeUpdatedTask = (tasks: Task[] | undefined) => - (tasks ?? []).filter((candidate) => candidate.id !== id); + try { + await previous; + await Promise.all([ + queryClient.cancelQueries({ queryKey: key }), + queryClient.cancelQueries({ queryKey: unscheduledTasksQueryKey }), + ]); - 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; + 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); - queryClient.setQueryData(key, applyUpdate); - queryClient.setQueryData(unscheduledTasksQueryKey, applyUpdate); - } + if (task && data.date !== undefined) { + const updatedTask = { ...task, ...data }; + const removeUpdatedTask = (tasks: Task[] | undefined) => + (tasks ?? []).filter((candidate) => candidate.id !== id); - return { previousTask, previousUnscheduledTask, release, tail }; - }, - onError: (_err, { id }, context) => { - if (context) { - const restoreTask = ( - queryKey: readonly string[], - snapshot: { - hadData: boolean; - index: number; - task: Task | undefined; - }, - ) => { - 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), - ]; + 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; - if ( - !snapshot.hadData && - queryClient.getQueryData(queryKey)?.length === 0 - ) { - queryClient.removeQueries({ queryKey, exact: true }); - } - }; + queryClient.setQueryData(key, applyUpdate); + queryClient.setQueryData( + unscheduledTasksQueryKey, + applyUpdate, + ); + } - restoreTask(key, context.previousTask); - restoreTask(unscheduledTasksQueryKey, context.previousUnscheduledTask); + 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; } - toast.error("タスクの更新に失敗しました"); }, - onSettled: (_data, _error, { id }, context) => { - const coordinator = taskUpdateCoordinators.get(queryClient); - if (!context || !coordinator) return; - - context.release(); - if (coordinator.locks.get(id) === context.tail) { - coordinator.locks.delete(id); - } - coordinator.activeCount -= 1; - - if (coordinator.activeCount === 0) { - taskUpdateCoordinators.delete(queryClient); - return queryClient.invalidateQueries({ queryKey: ["tasks"] }); + onError: (_err, { id }, context) => { + if (context) { + 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: (_data, _error, _variables, context) => + context + ? releaseTaskUpdate(queryClient, context.registration) + : undefined, }); }