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:
parent
9326d8fd2f
commit
afa80b60fe
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue