feat(ux): one silhouette from route fallback to detail content (#1944)

Opening a row on the customers, invoices, verifikat, supplier-invoice,
supplier, article and salary-run lists flashed three unrelated layouts:
the segment's list-shaped loading.tsx (or, for suppliers and articles, the
Hem-shaped dashboard fallback) during the RSC round trip, then the client
page's bare centred spinner in an h-64 box while it fetched, then the
content with a full layout change. Two flashes per click on the most
travelled drill-down path.

- components/common/DetailPageSkeleton.tsx: back link + title row + card
  grid + line block, the silhouette of a document/register detail page;
  InvoiceEditorSkeleton for the editor routes (same shape the Ny faktura
  dialog shows while its chunk loads).
- loading.tsx for every [id] segment (customers, invoices, invoices/edit,
  invoices/credit, bookkeeping, supplier-invoices, suppliers, articles,
  salary/runs) and for the two list segments that had none (suppliers,
  articles, cloned from customers/loading.tsx).
- The client pages render the same skeleton while they fetch instead of
  the centred Loader2, so the RSC fallback to client fallback handoff is
  invisible. The reference-data gates are already gone (A1 to A6); this
  only covers the primary-entity fetch.
- app/(dashboard)/__tests__/detail-loading-states.test.ts pins both.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 15:02:26 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 3ee3565d6d
commit 41424a1650
21 changed files with 283 additions and 45 deletions
@@ -0,0 +1,41 @@
/**
* Every [id] detail segment ships a route-level loading.tsx, and its client
* page no longer collapses into the bare centred spinner while it fetches:
* the RSC fallback and the client fallback share one silhouette
* (components/common/DetailPageSkeleton), so opening a row never flashes
* three unrelated layouts.
*/
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
const APP = path.resolve(__dirname, '..')
const DETAIL_SEGMENTS = [
'customers/[id]',
'invoices/[id]',
'invoices/[id]/edit',
'invoices/[id]/credit',
'bookkeeping/[id]',
'supplier-invoices/[id]',
'suppliers/[id]',
'articles/[id]',
'salary/runs/[id]',
]
const LIST_SEGMENTS = ['customers', 'invoices', 'suppliers', 'articles', 'supplier-invoices']
describe('detail and list segments have a route-level loading state', () => {
for (const segment of [...DETAIL_SEGMENTS, ...LIST_SEGMENTS]) {
it(`${segment}/loading.tsx exists`, () => {
expect(fs.existsSync(path.join(APP, segment, 'loading.tsx'))).toBe(true)
})
}
})
describe('detail pages do not gate on the bare centred spinner', () => {
for (const segment of DETAIL_SEGMENTS) {
it(`${segment}/page.tsx uses the shared skeleton, not h-64 + Loader2`, () => {
const source = fs.readFileSync(path.join(APP, segment, 'page.tsx'), 'utf8')
expect(source).not.toMatch(/justify-center h-64">\s*<Loader2/)
})
}
})
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the article detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton />
}
+2 -5
View File
@@ -22,6 +22,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import { parseArticleHouseworkType, workTypeLabel } from '@/lib/invoices/rot-rut-rules'
import type { Article, ArticleType, CreateArticleInput } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
const ARTICLE_TYPE_KEY: Record<ArticleType, string> = {
vara: 'type_vara',
@@ -198,11 +199,7 @@ export default function ArticleDetailPage({
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <DetailPageSkeleton />
}
if (!article) return null
+39
View File
@@ -0,0 +1,39 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function ArticlesLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + export menu + "Ny artikel" action (pills) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-32" />
<div className="flex gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
</div>
{/* Search toolbar */}
<Skeleton className="h-9 w-full" />
{/* Borderless table: header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-40" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the verifikat detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton />
}
+2 -6
View File
@@ -60,6 +60,7 @@ import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
// Snapshot of a struck line, as stored in journal_entry_rattelse_log.
type StruckLineSnapshot = {
@@ -344,12 +345,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
}, [fetchData])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
)
return <DetailPageSkeleton />
}
if (error || !entry) {
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the customer detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton />
}
+2 -5
View File
@@ -24,6 +24,7 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag
import { cn, formatDate } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
const CUSTOMER_TYPE_KEY: Record<CustomerType, string> = {
individual: 'type_individual',
@@ -201,11 +202,7 @@ export default function CustomerDetailPage({
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <DetailPageSkeleton />
}
if (!customer) return null
@@ -0,0 +1,6 @@
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the credit-note segment: the editor's own silhouette. */
export default function Loading() {
return <InvoiceEditorSkeleton />
}
@@ -24,6 +24,7 @@ import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { creditConfirmNumber } from '@/lib/invoices/display'
import type { Invoice, InvoiceItem, Customer } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -160,11 +161,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <InvoiceEditorSkeleton />
}
if (!invoice) {
@@ -0,0 +1,6 @@
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the invoice edit segment: the editor's own silhouette. */
export default function Loading() {
return <InvoiceEditorSkeleton />
}
+2 -6
View File
@@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import InvoiceEditor, { type InvoiceForEdit } from '@/components/invoices/InvoiceEditor'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import type { InvoiceItem } from '@/types'
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
/**
* Edit an existing DRAFT invoice. Loads the invoice + items, guards that it is
@@ -76,11 +76,7 @@ export default function EditInvoicePage({ params }: { params: Promise<{ id: stri
}, [id])
if (isLoading || !invoice) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <InvoiceEditorSkeleton />
}
return <InvoiceEditor mode="edit" initial={invoice} />
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the invoice detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton cards={3} />
}
+2 -5
View File
@@ -98,6 +98,7 @@ const PEPPOL_STATUS_KEYS = new Set([
])
const PEPPOL_SENDABLE_STATUSES = new Set<InvoiceStatus>(['draft', 'sent', 'overdue'])
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
// Why the downloaded file is not the invoice the customer received. One key
// per reason: "no archived copy exists" and "the archive could not be reached"
@@ -1171,11 +1172,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <DetailPageSkeleton cards={3} />
}
if (!invoice) {
@@ -0,0 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
/** Route-level fallback for a salary run: mirrors the page's own loading state (wide two-column layout). */
export default function Loading() {
return (
<div className="space-y-6" aria-busy="true" aria-live="polite">
<Skeleton className="h-9 w-60" />
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_280px] gap-8 space-y-6 lg:space-y-0">
<Skeleton className="rounded-lg h-48" />
<Skeleton className="rounded-lg h-64 hidden lg:block" />
</div>
</div>
)
}
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the supplier-invoice detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton cards={3} />
}
@@ -40,6 +40,7 @@ import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
interface EditableLine {
account_number: string
@@ -510,12 +511,7 @@ export default function SupplierInvoiceDetailPage() {
}
if (isLoading) {
return (
<div className="space-y-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-48 w-full" />
</div>
)
return <DetailPageSkeleton cards={3} />
}
if (!invoice) {
@@ -0,0 +1,6 @@
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Route-level fallback for the supplier detail segment: same silhouette the page renders while it fetches. */
export default function Loading() {
return <DetailPageSkeleton />
}
+2 -7
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -19,6 +18,7 @@ import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -145,12 +145,7 @@ export default function SupplierDetailPage() {
}
if (isLoading) {
return (
<div className="space-y-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-48 w-full" />
</div>
)
return <DetailPageSkeleton />
}
if (!supplier) {
+39
View File
@@ -0,0 +1,39 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function SuppliersLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + export menu + "Ny leverantör" action (pills) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-32" />
<div className="flex gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
</div>
{/* Search toolbar */}
<Skeleton className="h-9 w-full" />
{/* Borderless table: header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-40" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface DetailPageSkeletonProps {
/** Number of summary cards under the title row. */
cards?: 2 | 3
/** Full-width variant for MainContainer's wide routes (max-w-7xl). */
wide?: boolean
className?: string
}
/**
* Silhouette of a register/document detail page: back link, title row with
* action pills, then a card grid. Used both as the route-level loading.tsx of
* the [id] segments and as the client page's own loading state, so the
* handoff from the RSC fallback to the client fetch is a no-op visually
* instead of skeleton -> centred spinner -> content (three unrelated layouts
* on the most-travelled drill-down path).
*/
export function DetailPageSkeleton({ cards = 2, wide = false, className }: DetailPageSkeletonProps) {
return (
<div className={cn('space-y-8', className)} aria-busy="true" aria-live="polite">
{/* Back link */}
<Skeleton className="h-4 w-24" />
{/* Title (24px) + status pill + primary actions (pills) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-24 rounded-full" />
</div>
</div>
{/* Card grid */}
<div
className={cn(
'grid gap-6',
cards === 3 ? 'md:grid-cols-3' : 'md:grid-cols-2',
wide && 'lg:grid-cols-[minmax(0,1fr)_280px]',
)}
>
{Array.from({ length: cards }, (_, i) => (
<div key={i} className="space-y-3 rounded-lg border border-border p-5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
))}
</div>
{/* Line/table block */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
)
}
/**
* Silhouette of the invoice editor (single-column snabbflöde): heading, the
* customer/details block and the lines block. Matches the fallback the
* "Ny faktura" dialog shows while the editor chunk loads.
*/
export function InvoiceEditorSkeleton({ className }: { className?: string }) {
return (
<div className={cn('space-y-4', className)} aria-busy="true" aria-live="polite">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
)
}