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 { 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]);

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 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]);

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 {
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]);