perf(accounts): defer the account ledger prop on show

The show page loaded the full transaction ledger into the initial
Inertia payload on every visit — several MB for accounts with years of
history, all on the critical render path.

Defer the transactions prop so the page shell paints immediately and the
ledger arrives in a follow-up request behind a skeleton. It stays the
whole set on purpose: search and filtering run client-side over
decrypted rows, so cursor pagination would break search over encrypted
fields.
This commit is contained in:
Víctor Falcón 2026-07-03 17:04:45 +02:00
parent 021cb66643
commit c0ad01cfe3
4 changed files with 46 additions and 30 deletions

View File

@ -120,20 +120,20 @@ class AccountController extends Controller
}
}
// ponytail: load-all transactions for the account (server-side, replaces
// the old client-side sync-all-then-filter). Fine for a single account;
// switch to a deferred/cursor-paginated prop if accounts grow huge.
$transactions = $account->type->hasTransactionLedger()
? $account->transactions()
->with(['category', 'labels'])
->orderBy('transaction_date', 'desc')
->orderBy('id', 'desc')
->get()
: [];
return Inertia::render('Accounts/Show', [
'account' => $data,
'transactions' => $transactions,
// Deferred so the page shell paints without blocking on the ledger
// query/serialization. It stays the whole set because search and
// filtering run client-side over decrypted rows. ponytail: window it
// server-side only if one account's history gets big enough that the
// transfer itself hurts.
'transactions' => $account->type->hasTransactionLedger()
? Inertia::defer(fn () => $account->transactions()
->with(['category', 'labels'])
->orderBy('transaction_date', 'desc')
->orderBy('id', 'desc')
->get())
: [],
]);
}

View File

@ -7,6 +7,7 @@ import AccountShow from './Show';
vi.mock('@inertiajs/react', () => ({
Head: () => null,
router: { reload: vi.fn() },
Deferred: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('@/actions/App/Http/Controllers/AccountController', () => ({

View File

@ -39,6 +39,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
@ -63,7 +64,7 @@ import { Label as LabelType } from '@/types/label';
import { type Transaction } from '@/types/transaction';
import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import { Deferred, Head, router } from '@inertiajs/react';
import { ChevronDown, Pencil, Plus } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
@ -382,23 +383,34 @@ export default function AccountShow({
)}
{isTransactionalAccount(account) && (
<TransactionList
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
accountId={account.id}
transactions={transactions}
pageSize={50}
hideAccountFilter={true}
showActionsMenu={false}
maxHeight={600}
hideColumns={['bank', 'account']}
onBalanceUpdated={() =>
setChartRefreshKey((key) => key + 1)
<Deferred
data="transactions"
fallback={
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
}
/>
>
<TransactionList
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
accountId={account.id}
transactions={transactions}
pageSize={50}
hideAccountFilter={true}
showActionsMenu={false}
maxHeight={600}
hideColumns={['bank', 'account']}
onBalanceUpdated={() =>
setChartRefreshKey((key) => key + 1)
}
/>
</Deferred>
)}
</div>

View File

@ -238,7 +238,10 @@ test('account show includes the account transactions and excludes other accounts
->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('transactions', 2)
->missing('transactions')
->loadDeferredProps(fn ($reload) => $reload
->has('transactions', 2)
)
);
});