fix: Analyst ratings abort without reason on overview page
- trpcQuery/trpcMutate now throw 'Request timed out (8s)' instead of generic 'The operation was aborted.' so users know WHY the request failed - AnalystRatings adds cancelled guard (race condition fix) so stale promise results from symbol changes are ignored (matches OverviewPanel pattern) - Add Retry button on error state for timeout or other failures - useEffect cleanup returns cancelled flag setter
This commit is contained in:
@@ -52,17 +52,25 @@ export function AnalystRatings({ symbol }: Props) {
|
||||
const [consensus, setConsensus] = useState<{ strongBuy: number; buy: number; hold: number; sell: number; strongSell: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
api.institutional.analystRatings(symbol).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.error) { setError(res.error); setRatings(null); }
|
||||
else { setRatings(res.ratings); setConsensus(res.consensus); }
|
||||
}).catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [symbol]);
|
||||
}).catch((e: Error) => {
|
||||
if (cancelled) return;
|
||||
setError(e.message);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol, retryKey]);
|
||||
|
||||
const topFirms = useMemo(() => {
|
||||
if (!ratings) return [];
|
||||
@@ -76,7 +84,12 @@ export function AnalystRatings({ symbol }: Props) {
|
||||
|
||||
if (!symbol) return null;
|
||||
if (loading) return <div className="text-xs text-fg-muted py-2">Loading analyst ratings...</div>;
|
||||
if (error) return <div className="text-xs text-danger py-2">Analyst ratings: {error}</div>;
|
||||
if (error) return (
|
||||
<div className="text-xs text-danger py-2 flex items-center gap-2">
|
||||
<span>Analyst ratings: {error}</span>
|
||||
<button className="text-up hover:underline" onClick={() => setRetryKey((k) => k + 1)}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
if (!ratings || ratings.length === 0) return <div className="text-xs text-fg-muted py-2">No analyst ratings available for {symbol}.</div>;
|
||||
|
||||
const consensusText = consensus
|
||||
|
||||
@@ -396,6 +396,11 @@ async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return parse<T>(res);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
throw new Error(`Request timed out (${TRPC_TIMEOUT_MS / 1000}s)`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -413,6 +418,11 @@ async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return parse<T>(res);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
throw new Error(`Request timed out (${TRPC_TIMEOUT_MS / 1000}s)`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user