birchline/web · Pull Request #247

给任务列表的 mutation 加上乐观更新

MO
Mira Okafor
2 天前提交
mo/optimistic-tasks main
+142 / −38 改动 6 个文件

这个 PR 做了什么

风险一览

安全 值得一看 需要关注

文件

src/hooks/useOptimisticTasks.ts
需要关注 +58 −0
@@ -0,0 +1,58 @@
1+import { useMutation, useQueryClient } from '@tanstack/react-query';
2+import { updateTask, TaskPatch } from '../api/tasks';
3+import type { Task } from '../types/task';
4+
5+export function useOptimisticTasks(boardId: string) {
6+ const qc = useQueryClient();
7+ const key = ['tasks', boardId];
8+
9+ return useMutation({
10+ mutationFn: (patch: TaskPatch) => updateTask(patch),
11+ onMutate: async (patch) => {
12+ const prev = qc.getQueryData<Task[]>(key);
13+ qc.setQueryData<Task[]>(key, (old = []) =>
14+ old.map(t => t.id === patch.id ? { ...t, ...patch } : t)
15+ );
16+ return { prev };
17+ },
18+ onError: (_e, _p, ctx) => qc.setQueryData(key, ctx?.prev),
19+ });
20+}
第 11 行

阻塞onMutate 没有先调用 qc.cancelQueries(key)。如果在乐观写入和服务端响应之间正好插入一次后台 refetch,就会把乐观状态盖掉,UI 会闪回到旧值。

第 18 行

小建议回滚把列表还原了,但没有把错误显式抛出来。建议在这里接入现有的 pushToast,让用户知道这次切换没生效。

src/components/TaskList.tsx
值得一看 +31 −24
@@ -42,14 +42,17 @@ export function TaskList({ boardId }: Props) {
42 const { data: tasks } = useTasks(boardId);
43- const [pending, setPending] = useState<string | null>(null);
44-
45- async function toggle(task: Task) {
46- setPending(task.id);
47- await updateTask({ id: task.id, done: !task.done });
48- await refetch();
49- setPending(null);
50- }
43+ const { mutate, isPending } = useOptimisticTasks(boardId);
44+
45+ const toggle = (task: Task) =>
46+ mutate({ id: task.id, done: !task.done });
47
48 return (
49 <ul className="tasks">
50- {tasks?.map(t => <TaskRow key={t.id} task={t} busy={pending === t.id} />)}
50+ {tasks?.map(t => <TaskRow key={t.id} task={t} onToggle={toggle} />)}
51 </ul>
第 43 行

小建议isPending 被解构出来但从没用上。要么删掉,要么传给 TaskRow——让请求飞行期间复选框变灰一下。

src/api/tasks.ts
值得一看 +19 −6
@@ -12,10 +12,15 @@ export type TaskPatch = Partial<Task> & { id: string };
12
13-export async function updateTask(patch: TaskPatch) {
14- return http.patch(`/tasks/${patch.id}`, patch);
13+export async function updateTask(
14+ patch: TaskPatch,
15+ key = crypto.randomUUID(),
16+) {
17+ return http.patch(`/tasks/${patch.id}`, patch, {
18+ headers: { 'Idempotency-Key': key },
19+ });
20 }
第 15 行

阻塞把幂等键当默认参数生成,意味着 mutation 层每次重试都会拿到一个新的键,幂等就失效了。这个键应该在 onMutate 里只生成一次,然后一路传下去。

src/components/Toast.tsx 安全 +14 −2
新增了 variant="warning" 样式,并导出 pushToast。纯增量改动,对现有调用方没有行为影响。
src/types/task.ts 安全 +6 −2
Task.status 拓宽以包含 "archived",并新增可选的 updatedAt 时间戳。仅类型层面的改动。
src/components/__tests__/TaskList.test.tsx 安全 +14 −4
新增两个测试:一个断言点击后任务行同步更新,另一个断言 mock 请求 reject 时会回滚。本地都跑通了。