From afa80b60fe97ecc504847ef9c4a1e8c9a9c2c9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 3 Jul 2026 17:56:49 +0200 Subject: [PATCH] fix(accounts): remove double-skeleton flash on account show (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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, `` rendered an 8-plain-bar fallback. 2. Once data arrived, `` 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 `` 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). --- .../transactions/transaction-list.test.tsx | 23 ++++++ .../transactions/transaction-list.tsx | 73 ++++++++++--------- resources/js/pages/Accounts/Show.test.tsx | 1 + resources/js/pages/Accounts/Show.tsx | 14 ++-- 4 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 resources/js/components/transactions/transaction-list.test.tsx diff --git a/resources/js/components/transactions/transaction-list.test.tsx b/resources/js/components/transactions/transaction-list.test.tsx new file mode 100644 index 00000000..2bde144b --- /dev/null +++ b/resources/js/components/transactions/transaction-list.test.tsx @@ -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(); + + // 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); + }); +}); diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index a1a5d088..d8685fcb 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -81,6 +81,41 @@ import { UUID } from '@/types/uuid'; const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; +export function TransactionListSkeleton() { + return ( +
+
+
+ + + + +
+
+
+ +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ + +
+ + +
+ +
+ ))} +
+
+
+ + +
+
+ ); +} + interface TransactionRowProps { row: Row; virtualRow: VirtualItem; @@ -278,7 +313,9 @@ export function TransactionList({ const [transactions, setTransactions] = useState( [], ); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState( + providedTransactions === undefined, + ); const [sorting, setSorting] = useState([ { id: 'transaction_date', desc: true }, ]); @@ -1005,39 +1042,7 @@ export function TransactionList({ /> {isLoading || isSearching ? ( -
-
-
- - - - -
-
-
- -
- {Array.from({ length: 6 }).map((_, index) => ( -
- - -
- - -
- -
- ))} -
-
-
- - -
-
+ ) : ( <> ({ vi.mock('@/components/transactions/transaction-list', () => ({ TransactionList: () => null, + TransactionListSkeleton: () => null, })); vi.mock('@/components/bank-logo', () => ({ diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index c8ded843..d534ce39 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -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) && ( - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - } + fallback={} >