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:
Investor Flow Build
2026-07-25 11:42:33 -04:00
parent 7492317ebb
commit 8b51d16d7b
2 changed files with 27 additions and 4 deletions
+17 -4
View File
@@ -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
+10
View File
@@ -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);
}