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:
parent
02395b9ad4
commit
1387f7c2d7
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue