diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.jsx index a4ee68d..cc4348f 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { formatUSD, formatUSDWhole } from '@/lib/money'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; @@ -512,17 +512,22 @@ export default function AnalyticsPage() { include_skipped: includeSkipped, }), [billId, categoryId, includeInactive, includeSkipped, month, months, year]); + const requestSeq = useRef(0); const load = useCallback(async () => { + // Ignore out-of-order responses: only the newest request updates state, so a + // slow response for old params can't overwrite fresh data (or setState after + // unmount). Same guard the Bank ledger uses. + const seq = ++requestSeq.current; setLoading(true); setError(''); try { const result = await api.analyticsSummary(params); - setData(result); + if (seq === requestSeq.current) setData(result); } catch (err) { - setError(err.message || 'Failed to load analytics.'); + if (seq === requestSeq.current) setError(err.message || 'Failed to load analytics.'); toast.error(err.message || 'Failed to load analytics.'); } finally { - setLoading(false); + if (seq === requestSeq.current) setLoading(false); } }, [params]); diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index d80a995..c463a61 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -653,8 +653,12 @@ export default function SpendingPage() { } }, []); - // loadTransactions is exposed so pagination buttons can call it with a page arg + // loadTransactions is exposed so pagination buttons can call it with a page arg. + // The sequence guard ignores out-of-order responses (fast month/category nav or + // rapid paging) so a slow older request can't overwrite fresher data. + const txSeq = useRef(0); const loadTransactions = useCallback(async (page = 1) => { + const seq = ++txSeq.current; setTxLoading(true); setTxError(null); try { @@ -662,31 +666,35 @@ export default function SpendingPage() { if (activeCat === null) params.category_id = 'null'; else if (activeCat !== undefined) params.category_id = activeCat; const d = await api.spendingTransactions(params); + if (seq !== txSeq.current) return; // superseded setTransactions(d.transactions || []); setTxTotal(d.total || 0); setTxPages(d.pages || 1); setTxPage(page); } catch (err) { - setTxError(err.message || 'Failed to load transactions'); + if (seq === txSeq.current) setTxError(err.message || 'Failed to load transactions'); } finally { - setTxLoading(false); + if (seq === txSeq.current) setTxLoading(false); } }, [year, month, activeCat]); + const summarySeq = useRef(0); const loadSummary = useCallback(async () => { + const seq = ++summarySeq.current; setLoading(true); setSummaryError(null); try { const d = await api.spendingSummary({ year, month }); + if (seq !== summarySeq.current) return; // superseded setSummary(d); const bmap = {}; (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); setBudgets(bmap); if (d.category_groups) setCategoryGroups(d.category_groups); } catch (err) { - setSummaryError(err.message || 'Failed to load spending summary'); + if (seq === summarySeq.current) setSummaryError(err.message || 'Failed to load spending summary'); } finally { - setLoading(false); + if (seq === summarySeq.current) setLoading(false); } }, [year, month]); diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx index cc04513..fa0be77 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ArrowDown, @@ -250,11 +250,15 @@ export default function SummaryPage() { const [dropTargetId, setDropTargetId] = useState(null); const [movingBillId, setMovingBillId] = useState(null); + const requestSeq = useRef(0); const loadSummary = useCallback(async () => { + // Ignore out-of-order responses: only the newest month load updates state. + const seq = ++requestSeq.current; setLoading(true); setError(''); try { const result = await api.summary(selected.year, selected.month); + if (seq !== requestSeq.current) return; // superseded by a newer request setData(result); setStartingFirst(String(result.starting_amounts?.first_amount ?? 0)); setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0)); @@ -267,10 +271,10 @@ export default function SummaryPage() { setDropTargetId(null); setMovingBillId(null); } catch (err) { - setError(err.message || 'Summary could not be loaded.'); + if (seq === requestSeq.current) setError(err.message || 'Summary could not be loaded.'); toast.error(err.message || 'Summary could not be loaded.'); } finally { - setLoading(false); + if (seq === requestSeq.current) setLoading(false); } }, [selected.month, selected.year]);