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 { 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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue