cm_bot_v2/web/components/table-skeleton.tsx
yiekheng 9a4072129a perf(web): add route-level loading skeletons for instant tab switching
The 'switching is laggy with many accounts' report — root cause is that
both /page.tsx and /users/page.tsx are Server Components that block on
the API fetch before sending any HTML. During the wait, the previous
route stays frozen (no spinner, no feedback) — the user perceives a 'lag'
that grows with row count.

App Router's loading.tsx convention solves this: Next.js renders it
INSTANTLY on navigation, then streams in the real RSC tree once the data
fetch resolves. The skeleton matches the shape of the real shell + a few
placeholder rows so the swap is layout-stable.

Files:
- web/components/table-skeleton.tsx — shared skeleton (PageHead + N rows)
- web/app/loading.tsx — used for /
- web/app/users/loading.tsx — used for /users

If row counts keep growing past a few hundred and the table itself
becomes the bottleneck (vs the network fetch this addresses), the next
step is pagination: accept ?limit=&offset= on /acc/ and /user/ in
cm_api.py and add a 'Load more' button (or a virtual list) at the
table-component layer.
2026-05-03 11:14:13 +08:00

51 lines
2.1 KiB
TypeScript

type Props = { eyebrow: string; title: string; rows?: number };
/**
* Lightweight skeleton that mimics the AccountsTable / UsersTable shell.
* Rendered by app/loading.tsx and app/users/loading.tsx — Next.js shows
* this immediately on tab navigation, then streams in the real Server
* Component once its data fetch resolves. Without it, the previous
* route's UI freezes until the fetch finishes (the "tab switch is
* laggy" symptom).
*
* The pulse animation comes from Tailwind's animate-pulse on each
* placeholder bar; no JS, no layout shift when the real content swaps in.
*/
export default function TableSkeleton({ eyebrow, title, rows = 8 }: Props) {
return (
<div className="space-y-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{eyebrow}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
{title}
<span className="ml-2 inline-block h-4 w-8 animate-pulse rounded bg-zinc-200 align-middle" />
</h1>
</div>
<div className="flex items-center gap-2">
<span className="h-8 w-20 animate-pulse rounded-full bg-zinc-200" />
<span className="h-8 w-16 animate-pulse rounded-full bg-zinc-200" />
</div>
</div>
<ul className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<li
key={i}
className="rounded-xl bg-white px-4 py-3 ring-1 ring-zinc-200/60"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="h-4 w-24 animate-pulse rounded bg-zinc-200" />
<span className="h-4 w-16 animate-pulse rounded-full bg-zinc-100" />
</div>
<span className="h-6 w-6 animate-pulse rounded-md bg-zinc-100" />
</div>
</li>
))}
</ul>
</div>
);
}