fix(client): guard param-driven data loaders against out-of-order responses (R3)

The month/filter-driven loaders on Analytics, Summary, and Spending (x2)
fetched + setState with no race guard, so a slow response for old params could
overwrite fresher data (or setState after unmount) on rapid month/category nav.
Added the request-sequence guard already used by the Bank ledger (newest
request wins; stale ignored). Bills/Subscriptions load once ([] deps) so they
weren't at risk; BankTransactions already had the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 19:57:04 -05:00
parent 02395b9ad4
commit 1387f7c2d7
3 changed files with 29 additions and 12 deletions

View File

@ -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 { formatUSD, formatUSDWhole } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -512,17 +512,22 @@ export default function AnalyticsPage() {
include_skipped: includeSkipped, include_skipped: includeSkipped,
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]); }), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
const requestSeq = useRef(0);
const load = useCallback(async () => { 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); setLoading(true);
setError(''); setError('');
try { try {
const result = await api.analyticsSummary(params); const result = await api.analyticsSummary(params);
setData(result); if (seq === requestSeq.current) setData(result);
} catch (err) { } 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.'); toast.error(err.message || 'Failed to load analytics.');
} finally { } finally {
setLoading(false); if (seq === requestSeq.current) setLoading(false);
} }
}, [params]); }, [params]);

View File

@ -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 loadTransactions = useCallback(async (page = 1) => {
const seq = ++txSeq.current;
setTxLoading(true); setTxLoading(true);
setTxError(null); setTxError(null);
try { try {
@ -662,31 +666,35 @@ export default function SpendingPage() {
if (activeCat === null) params.category_id = 'null'; if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat; else if (activeCat !== undefined) params.category_id = activeCat;
const d = await api.spendingTransactions(params); const d = await api.spendingTransactions(params);
if (seq !== txSeq.current) return; // superseded
setTransactions(d.transactions || []); setTransactions(d.transactions || []);
setTxTotal(d.total || 0); setTxTotal(d.total || 0);
setTxPages(d.pages || 1); setTxPages(d.pages || 1);
setTxPage(page); setTxPage(page);
} catch (err) { } catch (err) {
setTxError(err.message || 'Failed to load transactions'); if (seq === txSeq.current) setTxError(err.message || 'Failed to load transactions');
} finally { } finally {
setTxLoading(false); if (seq === txSeq.current) setTxLoading(false);
} }
}, [year, month, activeCat]); }, [year, month, activeCat]);
const summarySeq = useRef(0);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
const seq = ++summarySeq.current;
setLoading(true); setLoading(true);
setSummaryError(null); setSummaryError(null);
try { try {
const d = await api.spendingSummary({ year, month }); const d = await api.spendingSummary({ year, month });
if (seq !== summarySeq.current) return; // superseded
setSummary(d); setSummary(d);
const bmap = {}; const bmap = {};
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap); setBudgets(bmap);
if (d.category_groups) setCategoryGroups(d.category_groups); if (d.category_groups) setCategoryGroups(d.category_groups);
} catch (err) { } catch (err) {
setSummaryError(err.message || 'Failed to load spending summary'); if (seq === summarySeq.current) setSummaryError(err.message || 'Failed to load spending summary');
} finally { } finally {
setLoading(false); if (seq === summarySeq.current) setLoading(false);
} }
}, [year, month]); }, [year, month]);

View File

@ -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 { toast } from 'sonner';
import { import {
ArrowDown, ArrowDown,
@ -250,11 +250,15 @@ export default function SummaryPage() {
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null); const [movingBillId, setMovingBillId] = useState(null);
const requestSeq = useRef(0);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
// Ignore out-of-order responses: only the newest month load updates state.
const seq = ++requestSeq.current;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const result = await api.summary(selected.year, selected.month); const result = await api.summary(selected.year, selected.month);
if (seq !== requestSeq.current) return; // superseded by a newer request
setData(result); setData(result);
setStartingFirst(String(result.starting_amounts?.first_amount ?? 0)); setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0)); setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
@ -267,10 +271,10 @@ export default function SummaryPage() {
setDropTargetId(null); setDropTargetId(null);
setMovingBillId(null); setMovingBillId(null);
} catch (err) { } 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.'); toast.error(err.message || 'Summary could not be loaded.');
} finally { } finally {
setLoading(false); if (seq === requestSeq.current) setLoading(false);
} }
}, [selected.month, selected.year]); }, [selected.month, selected.year]);