fix(accounts): remove double-skeleton flash on account show (#634)

## Problem

Since #632 the account show page loads its transaction ledger via a
**deferred** Inertia prop. On first load of a transactional account the
user saw **two different loading skeletons back-to-back**, then the
table — a visible shape jump:

1. During the deferred network round-trip, `<Deferred>` rendered an
8-plain-bar fallback.
2. Once data arrived, `<TransactionList>` mounted with `isLoading`
initialized to `true` and rendered its **own, differently-shaped**
internal table skeleton for ~1 frame.
3. Then the real table.

Sequence: `plain-bars skeleton → table-shaped skeleton (~1 frame) →
table`.

## Fix

- Extract the internal table skeleton into a shared, exported
`TransactionListSkeleton`.
- Use it for **both** the `<Deferred>` fallback (`Accounts/Show.tsx`)
**and** `TransactionList`'s own `isLoading` branch — one consistent
skeleton, zero duplication.
- Initialize `isLoading` from `providedTransactions === undefined`, so
the redundant loading frame is dropped when data is already present at
mount.

### Before → After

| Phase | Before | After |
|-------|--------|-------|
| Deferred fetch | 8 plain bars | table-shaped skeleton |
| TransactionList mount | table-shaped skeleton (~1 frame) | (none —
data already present) |
| Loaded | table | table |

Result: a single, consistent, table-shaped loading state — no shape
jump.

## Shared-component safety

`TransactionList` is also used by the **budgets** show page
(`budgets/show.tsx`), which always passes a resolved `transactions`
array behind its own loading gate — so the `isLoading` init change keeps
that path rendering the table immediately. The transactions **index**
page has its own table and does not use `TransactionList`. Accounts with
zero transactions still render an empty table (unchanged).

## Tests

- New `transaction-list.test.tsx` asserts `TransactionListSkeleton`
renders the table-shaped skeleton (bordered container + many
placeholders), i.e. not the old plain-bars shape.
- `Accounts/Show.test.tsx` mock updated to export
`TransactionListSkeleton`; existing tests stay green.
- `bun run format`, `bun run lint` clean. No new TypeScript errors
introduced (pre-existing count unchanged).
This commit is contained in:
Víctor Falcón 2026-07-03 17:56:49 +02:00 committed by GitHub
parent 9326d8fd2f
commit afa80b60fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 68 additions and 43 deletions

View File

@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { TransactionListSkeleton } from './transaction-list';
describe('TransactionListSkeleton', () => {
it('renders the table-shaped skeleton shared by the deferred fallback and the internal loading state', () => {
const { container } = render(<TransactionListSkeleton />);
// The table-shaped skeleton is wrapped in a bordered container, which is
// what distinguishes it from the old plain-bars fallback. Using the same
// component in both loading phases is what removes the double-skeleton jump.
expect(
container.querySelector('.overflow-hidden.rounded-md.border'),
).not.toBeNull();
// Far more placeholders than the old 8-bar fallback: header row, section
// header, and six body rows, each mimicking the real table layout.
expect(
container.querySelectorAll('[data-slot="skeleton"]').length,
).toBeGreaterThan(8);
});
});

View File

@ -81,6 +81,41 @@ import { UUID } from '@/types/uuid';
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
export function TransactionListSkeleton() {
return (
<div className="space-y-4">
<div className="overflow-hidden rounded-md border">
<div className="grid grid-cols-4 gap-4 border-b p-4">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-64" />
<Skeleton className="h-5 w-20 justify-self-end" />
</div>
<div className="divide-y">
<div className="bg-muted/50 px-4 py-2">
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="grid grid-cols-4 gap-4 p-4">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-28" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-4 w-20 justify-self-end" />
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-9 w-48" />
</div>
</div>
);
}
interface TransactionRowProps {
row: Row<DecryptedTransaction>;
virtualRow: VirtualItem;
@ -278,7 +313,9 @@ export function TransactionList({
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(
providedTransactions === undefined,
);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'transaction_date', desc: true },
]);
@ -1005,39 +1042,7 @@ export function TransactionList({
/>
{isLoading || isSearching ? (
<div className="space-y-4">
<div className="overflow-hidden rounded-md border">
<div className="grid grid-cols-4 gap-4 border-b p-4">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-64" />
<Skeleton className="h-5 w-20 justify-self-end" />
</div>
<div className="divide-y">
<div className="bg-muted/50 px-4 py-2">
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-4 gap-4 p-4"
>
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-28" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-4 w-20 justify-self-end" />
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-9 w-48" />
</div>
</div>
<TransactionListSkeleton />
) : (
<>
<DataTable

View File

@ -65,6 +65,7 @@ vi.mock('@/components/transactions/edit-transaction-dialog', () => ({
vi.mock('@/components/transactions/transaction-list', () => ({
TransactionList: () => null,
TransactionListSkeleton: () => null,
}));
vi.mock('@/components/bank-logo', () => ({

View File

@ -17,7 +17,10 @@ import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import { MobileBackButton } from '@/components/mobile-back-button';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { TransactionList } from '@/components/transactions/transaction-list';
import {
TransactionList,
TransactionListSkeleton,
} from '@/components/transactions/transaction-list';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
@ -39,7 +42,6 @@ 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';
@ -385,13 +387,7 @@ export default function AccountShow({
{isTransactionalAccount(account) && (
<Deferred
data="transactions"
fallback={
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
}
fallback={<TransactionListSkeleton />}
>
<TransactionList
categories={categories}