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 };
}