fix(frontend): RefineView 取消在途 refine——AbortController 防段切换竞态(CR-H12)
This commit is contained in:
@@ -28,15 +28,17 @@ export function RefineView({
|
|||||||
onClose,
|
onClose,
|
||||||
}: RefineViewProps) {
|
}: RefineViewProps) {
|
||||||
const refiner = useRefine();
|
const refiner = useRefine();
|
||||||
|
const { refine, abort } = refiner;
|
||||||
|
|
||||||
// 进入即触发回炉(段非空)。
|
// 进入即触发回炉(段非空);清理时取消在途请求,避免旧段迟到响应覆盖新段(CR-H12)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (segment.trim().length > 0) {
|
if (segment.trim().length > 0) {
|
||||||
void refiner.refine(projectId, chapterNo, segment);
|
void refine(projectId, chapterNo, segment);
|
||||||
}
|
}
|
||||||
// 仅在段变化时触发。
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
abort();
|
||||||
}, [segment, projectId, chapterNo]);
|
};
|
||||||
|
}, [segment, projectId, chapterNo, refine, abort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -122,4 +122,40 @@ describe("useRefine", () => {
|
|||||||
expect(result.current.status).toBe("idle");
|
expect(result.current.status).toBe("idle");
|
||||||
expect(result.current.result).toBeNull();
|
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<unknown>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
@@ -24,6 +24,8 @@ export interface UseRefine {
|
|||||||
segment: string,
|
segment: string,
|
||||||
instruction?: string,
|
instruction?: string,
|
||||||
) => Promise<RefineResult | null>;
|
) => Promise<RefineResult | null>;
|
||||||
|
// 取消在途回炉(段切换/卸载时调用)——不触碰 state,安全用于 effect 清理。
|
||||||
|
abort: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +34,15 @@ export interface UseRefine {
|
|||||||
export function useRefine(): UseRefine {
|
export function useRefine(): UseRefine {
|
||||||
const [status, setStatus] = useState<RefineStatus>("idle");
|
const [status, setStatus] = useState<RefineStatus>("idle");
|
||||||
const [result, setResult] = useState<RefineResult | null>(null);
|
const [result, setResult] = useState<RefineResult | null>(null);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const refine = useCallback<UseRefine["refine"]>(
|
const refine = useCallback<UseRefine["refine"]>(
|
||||||
async (projectId, chapterNo, segment, instruction) => {
|
async (projectId, chapterNo, segment, instruction) => {
|
||||||
|
// 取消上一次在途请求,换新 controller(段切换竞态防护,CR-H12)。
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
setStatus("refining");
|
setStatus("refining");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
@@ -46,8 +53,11 @@ export function useRefine(): UseRefine {
|
|||||||
path: { project_id: projectId, chapter_no: chapterNo },
|
path: { project_id: projectId, chapter_no: chapterNo },
|
||||||
},
|
},
|
||||||
body: buildRefineRequest(segment, instruction),
|
body: buildRefineRequest(segment, instruction),
|
||||||
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 已被后续请求/卸载中止:丢弃迟到响应,不写状态。
|
||||||
|
if (controller.signal.aborted) return null;
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
@@ -67,6 +77,8 @@ export function useRefine(): UseRefine {
|
|||||||
setStatus("done");
|
setStatus("done");
|
||||||
return outcome;
|
return outcome;
|
||||||
} catch {
|
} catch {
|
||||||
|
// 主动中止(abort)触发的异常不该报错 toast。
|
||||||
|
if (controller.signal.aborted) return null;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
toast("回炉请求异常,请检查网络。", "error");
|
toast("回炉请求异常,请检查网络。", "error");
|
||||||
return null;
|
return null;
|
||||||
@@ -75,10 +87,18 @@ export function useRefine(): UseRefine {
|
|||||||
[toast],
|
[toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 仅取消在途请求,不触碰 state——可安全用于 effect 清理 / 卸载。
|
||||||
|
const abort = useCallback((): void => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback((): void => {
|
const reset = useCallback((): void => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { status, result, refine, reset };
|
return { status, result, refine, abort, reset };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user