From 5c02792e5f72ae8717e27df98fe1dee6cf18d1dc Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 11:08:21 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20RefineView=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=9C=A8=E9=80=94=20refine=E2=80=94=E2=80=94AbortCont?= =?UTF-8?q?roller=20=E9=98=B2=E6=AE=B5=E5=88=87=E6=8D=A2=E7=AB=9E=E6=80=81?= =?UTF-8?q?=EF=BC=88CR-H12=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components/style/RefineView.tsx | 12 ++++---- apps/web/lib/style/useRefine.test.ts | 36 ++++++++++++++++++++++++ apps/web/lib/style/useRefine.ts | 24 ++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/web/components/style/RefineView.tsx b/apps/web/components/style/RefineView.tsx index b4c0d62..fa1dbf4 100644 --- a/apps/web/components/style/RefineView.tsx +++ b/apps/web/components/style/RefineView.tsx @@ -28,15 +28,17 @@ export function RefineView({ onClose, }: RefineViewProps) { const refiner = useRefine(); + const { refine, abort } = refiner; - // 进入即触发回炉(段非空)。 + // 进入即触发回炉(段非空);清理时取消在途请求,避免旧段迟到响应覆盖新段(CR-H12)。 useEffect(() => { if (segment.trim().length > 0) { - void refiner.refine(projectId, chapterNo, segment); + void refine(projectId, chapterNo, segment); } - // 仅在段变化时触发。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segment, projectId, chapterNo]); + return () => { + abort(); + }; + }, [segment, projectId, chapterNo, refine, abort]); return (
{ expect(result.current.status).toBe("idle"); expect(result.current.result).toBeNull(); }); + + it("中止后迟到的响应不覆盖状态(段切换竞态)", async () => { + // Arrange:post 挂起,手动兑现以模拟在途请求。 + let resolvePost!: (v: unknown) => void; + const pending = new Promise((r) => { + resolvePost = r; + }); + post.mockReturnValue(pending); + + const { result } = renderHook(() => useRefine()); + + // Act:发起回炉(进入 refining),随后 abort(模拟切段取消在途)。 + let refinePromise!: Promise; + act(() => { + refinePromise = result.current.refine("p1", 1, "新段"); + }); + expect(result.current.status).toBe("refining"); + + act(() => { + result.current.abort(); + }); + + // 迟到的旧响应兑现——应被丢弃:不写状态、不弹 toast。 + await act(async () => { + resolvePost({ + data: { original: "旧段", refined: "旧改写" }, + error: null, + }); + await refinePromise; + }); + + // Assert:结果仍为空、未落到 done、未误报 toast。 + expect(result.current.result).toBeNull(); + expect(result.current.status).not.toBe("done"); + expect(toast).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/lib/style/useRefine.ts b/apps/web/lib/style/useRefine.ts index fd0f159..dedeb9a 100644 --- a/apps/web/lib/style/useRefine.ts +++ b/apps/web/lib/style/useRefine.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { api } from "@/lib/api/client"; import { useToast } from "@/components/Toast"; @@ -24,6 +24,8 @@ export interface UseRefine { segment: string, instruction?: string, ) => Promise; + // 取消在途回炉(段切换/卸载时调用)——不触碰 state,安全用于 effect 清理。 + abort: () => void; reset: () => void; } @@ -32,10 +34,15 @@ export interface UseRefine { export function useRefine(): UseRefine { const [status, setStatus] = useState("idle"); const [result, setResult] = useState(null); + const controllerRef = useRef(null); const toast = useToast(); const refine = useCallback( async (projectId, chapterNo, segment, instruction) => { + // 取消上一次在途请求,换新 controller(段切换竞态防护,CR-H12)。 + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; setStatus("refining"); setResult(null); try { @@ -46,8 +53,11 @@ export function useRefine(): UseRefine { path: { project_id: projectId, chapter_no: chapterNo }, }, body: buildRefineRequest(segment, instruction), + signal: controller.signal, }, ); + // 已被后续请求/卸载中止:丢弃迟到响应,不写状态。 + if (controller.signal.aborted) return null; if (error || !data) { setStatus("error"); const code = errorCode(error); @@ -67,6 +77,8 @@ export function useRefine(): UseRefine { setStatus("done"); return outcome; } catch { + // 主动中止(abort)触发的异常不该报错 toast。 + if (controller.signal.aborted) return null; setStatus("error"); toast("回炉请求异常,请检查网络。", "error"); return null; @@ -75,10 +87,18 @@ export function useRefine(): UseRefine { [toast], ); + // 仅取消在途请求,不触碰 state——可安全用于 effect 清理 / 卸载。 + const abort = useCallback((): void => { + controllerRef.current?.abort(); + controllerRef.current = null; + }, []); + const reset = useCallback((): void => { + controllerRef.current?.abort(); + controllerRef.current = null; setStatus("idle"); setResult(null); }, []); - return { status, result, refine, reset }; + return { status, result, refine, abort, reset }; }