From 9fbc28fa33c7441c30205ec48601fd32fd31161f Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 12:43:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-analysis):=20per-call=20AbortControlle?= =?UTF-8?q?r,=20=D0=BD=D0=B5=20shared=20cancelledRef=20(#1242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конкурентные analyze делили один cancelledRef: новая мутация ставила ref=false и воскрешала спящий poll-цикл предыдущей — лишние fetch-status/ POST analyze и setFetchingState(null) затирали баннер активного запроса. Patch: shared boolean ref → per-call AbortController, signal проброшен в apiFetch/apiFetchWithStatus, добавлен abortableSleep чтобы цикл просыпался мгновенно на cancel. setFetchingState(null) guarded signal.aborted — зомби-цикл не может перезаписать состояние актуальной мутации. 82/82 frontend тестов зелёные. Closes #1242 --- frontend/src/hooks/useSiteAnalysis.ts | 73 +++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 7abd1a64..f06ee217 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -51,6 +51,31 @@ export interface AnalyzeOptions { weights?: Record | null; } +/** + * Sleep, but resolve early if the AbortSignal fires. + * + * Используем чтобы цикл polling'а пробуждался сразу при cancel(), + * а не ждал полные POLL_INTERVAL_MS до следующей проверки. + */ +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + /** * Custom hook для analyze flow с graceful on-demand fetch fallback. * @@ -60,6 +85,12 @@ export interface AnalyzeOptions { * - fetching: получили 202, идёт polling /fetch-status * - data set: ParcelAnalysis отрисовываем * - error: not_in_nspd / invalid_format / failed + * + * Отмена (issue #1242): per-call AbortController. cancel() абортит ТОЛЬКО + * текущий цикл — новая мутация создаёт свой controller, поэтому + * "воскресший" poll-цикл предыдущего вызова не может затронуть состояние + * актуального. setFetchingState вызывается только если signal не aborted — + * иначе баннер ETA/cancel активного запроса был бы перезаписан зомби-циклом. */ export function useSiteAnalysis() { const [fetchingState, setFetchingState] = useState<{ @@ -67,8 +98,9 @@ export function useSiteAnalysis() { jobId: number; etaSeconds: number; } | null>(null); - // ref для cancel polling — invalidates in-flight iteration - const cancelledRef = useRef(false); + // Per-call AbortController. cancel() абортит только текущий цикл; + // конкурентные мутации не делят токен отмены (фикс #1242). + const abortRef = useRef(null); const mutation = useMutation({ mutationFn: async ({ @@ -78,7 +110,13 @@ export function useSiteAnalysis() { cad: string; options?: AnalyzeOptions; }): Promise => { - cancelledRef.current = false; + // Абортим предыдущий in-flight цикл (если ещё спит / поллит) + // и создаём новый controller для текущего вызова. + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + setFetchingState(null); // Build optional query string for weight profile params. @@ -106,6 +144,7 @@ export function useSiteAnalysis() { ParcelAnalysis | AnalyzeAcceptedResponse >(analyzeUrl(cad), { method: "POST", + signal, ...(bodyPayload ? { body: bodyPayload, @@ -121,6 +160,11 @@ export function useSiteAnalysis() { // 202 Accepted — entering polling mode if (first.status === 202) { const accepted = first.body as AnalyzeAcceptedResponse; + // Guard: не перезаписываем баннер если этот вызов уже отменили + // (succeeded race: cancel пришёл во время первого POST). + if (signal.aborted) { + throw new Error("Загрузка отменена пользователем"); + } setFetchingState({ cadNum: cad, jobId: accepted.job_id, @@ -129,12 +173,13 @@ export function useSiteAnalysis() { // Poll loop for (let i = 0; i < POLL_MAX_ITERATIONS; i++) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - if (cancelledRef.current) { + await abortableSleep(POLL_INTERVAL_MS, signal); + if (signal.aborted) { throw new Error("Загрузка отменена пользователем"); } const status = await apiFetch( `/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`, + { signal }, ); if (status.status === "ready") { // Re-trigger analyze — should be 200 now. @@ -144,6 +189,7 @@ export function useSiteAnalysis() { // mutation сразу резолвится с data — render skipped. const second = await apiFetch(analyzeUrl(cad), { method: "POST", + signal, ...(bodyPayload ? { body: bodyPayload, @@ -151,11 +197,15 @@ export function useSiteAnalysis() { } : {}), }); - setFetchingState(null); + // Только если этот вызов всё ещё актуален — иначе зомби-цикл + // стирает баннер свежей мутации (#1242). + if (!signal.aborted) { + setFetchingState(null); + } return second; } if (status.status === "not_in_nspd") { - setFetchingState(null); + if (!signal.aborted) setFetchingState(null); throw new HTTPError( 404, status, @@ -163,7 +213,7 @@ export function useSiteAnalysis() { ); } if (status.status === "failed") { - setFetchingState(null); + if (!signal.aborted) setFetchingState(null); throw new HTTPError( 503, status, @@ -171,7 +221,7 @@ export function useSiteAnalysis() { ); } if (status.status === "invalid_format") { - setFetchingState(null); + if (!signal.aborted) setFetchingState(null); throw new HTTPError( 400, status, @@ -181,7 +231,7 @@ export function useSiteAnalysis() { // status === "fetching" → continue polling } // Polling exhausted (2 min) - setFetchingState(null); + if (!signal.aborted) setFetchingState(null); throw new Error( "Загрузка длится слишком долго (>2 мин). Попробуйте позже.", ); @@ -192,7 +242,8 @@ export function useSiteAnalysis() { }); const cancel = useCallback(() => { - cancelledRef.current = true; + abortRef.current?.abort(); + abortRef.current = null; setFetchingState(null); mutation.reset(); }, [mutation]);