Stabilize the decision workspace data boundaries
The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests. Constraint: Preserve existing global scan-terminal class names and API responses during the refactor Constraint: No new dependencies; keep this as a file-boundary cleanup Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
toRemoteError,
|
||||
toRemoteLoading,
|
||||
toRemoteSuccess,
|
||||
type RemoteData,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
|
||||
type RunRemoteQueryOptions<T> = {
|
||||
onSuccess?: (data: T) => void;
|
||||
request: (signal: AbortSignal) => Promise<T>;
|
||||
showLoading?: boolean;
|
||||
};
|
||||
|
||||
export function useRemoteDataQuery<T>() {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [remote, setRemote] = useState<RemoteData<T>>({ status: "idle" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
requestSeqRef.current += 1;
|
||||
abort();
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setRemote({ status: "idle" });
|
||||
}, [abort]);
|
||||
|
||||
const setSuccess = useCallback(
|
||||
(nextData: T) => {
|
||||
requestSeqRef.current += 1;
|
||||
abort();
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setData(nextData);
|
||||
setRemote(toRemoteSuccess(nextData));
|
||||
},
|
||||
[abort],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async ({
|
||||
onSuccess,
|
||||
request,
|
||||
showLoading = false,
|
||||
}: RunRemoteQueryOptions<T>) => {
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
if (showLoading) {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setRemote((current) => toRemoteLoading(current));
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await request(controller.signal);
|
||||
if (requestSeq !== requestSeqRef.current) return null;
|
||||
setData(payload);
|
||||
setRemote(toRemoteSuccess(payload));
|
||||
setError(null);
|
||||
onSuccess?.(payload);
|
||||
return payload;
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted || requestSeq !== requestSeqRef.current) {
|
||||
return null;
|
||||
}
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setError(message);
|
||||
setRemote((current) => toRemoteError(caught, current));
|
||||
return null;
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isLoading = useCallback(() => loadingRef.current, []);
|
||||
|
||||
useEffect(() => abort, [abort]);
|
||||
|
||||
return {
|
||||
abort,
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
loading,
|
||||
remote,
|
||||
reset,
|
||||
run,
|
||||
setSuccess,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user