fix(underlag): render the workspace skeleton while the route loads (#1556)

The extension loading.tsx branched on an x-pathname request header that
middleware only ever set on the response, so the pathname always read
empty, the fullscreen branch was dead code, and Dokumentinkorg loaded
behind the old inline-shell silhouette. Branch on the client pathname
instead (same convention as the dashboard loading.tsx), and extract the
skeleton into one shared component used by both the route fallback and
the workspace's client-fetch state, updated to the post-rebuild layout:
full-bleed, no card wrapper, filter dropdown instead of the removed
pill row.

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-13 11:19:00 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 8f1b1fb5cd
commit 18755a37ec
3 changed files with 64 additions and 102 deletions
+12 -54
View File
@@ -1,25 +1,29 @@
import { headers } from 'next/headers'
'use client'
import { usePathname } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton'
// Mirror of FULLSCREEN_WORKSPACES in ExtensionWorkspaceLoader. loading.tsx
// can't read route params, so we inspect the forwarded x-pathname header to
// branch the skeleton shape: the parent dashboard loading.tsx renders a
// metrics dashboard shape that has nothing to do with extension workspaces.
// can't read route params, so we branch on the client pathname: the parent
// dashboard loading.tsx renders a metrics dashboard shape that has nothing
// to do with extension workspaces. (This must stay a client component: the
// x-pathname header middleware sets goes on the response, so headers() in a
// server loading file never sees it and the fullscreen branch went dead.)
const FULLSCREEN_WORKSPACES = new Set(['general/invoice-inbox'])
export default async function ExtensionWorkspaceLoading() {
const h = await headers()
const pathname = h.get('x-pathname') ?? ''
export default function ExtensionWorkspaceLoading() {
const pathname = usePathname()
const match = pathname.match(/^\/e\/([^/]+)\/([^/]+)/)
const sector = match?.[1] ?? ''
const slug = match?.[2] ?? ''
const key = `${sector}/${slug}`
if (FULLSCREEN_WORKSPACES.has(key)) {
return <FullScreenWorkspaceSkeleton />
return <InvoiceInboxSkeleton />
}
const definition = sector && slug ? getExtensionDefinition(sector, slug) : undefined
@@ -101,49 +105,3 @@ function TicSkeleton() {
</div>
)
}
function FullScreenWorkspaceSkeleton() {
return (
<div className="h-[calc(100vh-1px)] md:h-full p-4 md:p-6">
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden">
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="hidden md:block h-3 w-56" />
</div>
<Skeleton className="h-8 w-28 shrink-0" />
</header>
<div className="flex-1 grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)_340px] min-h-0">
<aside className="border-b xl:border-b-0 xl:border-r overflow-hidden bg-muted/20 pt-3">
<div className="px-3 pb-3 space-y-2 border-b">
<Skeleton className="h-8 w-full" />
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-10 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
</div>
<ul>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="border-b px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 shrink-0" />
<Skeleton className="h-3.5 flex-1 max-w-[180px]" />
</div>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
</li>
))}
</ul>
</aside>
<main className="overflow-hidden bg-muted/10 hidden xl:block" />
<aside className="border-l overflow-hidden hidden xl:block" />
</div>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import { Skeleton } from '@/components/ui/skeleton'
// Single source of truth for the Dokumentinkorg loading silhouette, rendered
// by both the route-level loading.tsx and the workspace's own client-fetch
// state so the route fallback, the fetch shell, and the loaded UI are one
// shape with no reflow. Mirrors the live layout: full-bleed top bar +
// 3-pane grid, list pane with search + filter-dropdown trigger (PR #1524
// replaced the old filter-pill row with a single full-width dropdown).
export default function InvoiceInboxSkeleton() {
return (
<div className="h-[calc(100vh-1px)] md:h-full">
<div className="h-full flex flex-col overflow-hidden">
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="hidden md:block h-3 w-56" />
</div>
<Skeleton className="h-8 w-28 shrink-0" />
</header>
<div className="flex-1 grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)_340px] min-h-0">
<aside className="border-b xl:border-b-0 xl:border-r overflow-hidden bg-muted/20 pt-3">
<div className="px-3 pb-3 space-y-2 border-b">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<ul>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="border-b px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 shrink-0" />
<Skeleton className="h-3.5 flex-1 max-w-[180px]" />
</div>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
</li>
))}
</ul>
</aside>
<main className="overflow-hidden bg-muted/10 hidden xl:block" />
<aside className="border-l overflow-hidden hidden xl:block" />
</div>
</div>
</div>
)
}
@@ -52,6 +52,7 @@ import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton'
import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark'
import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt'
import { createClient } from '@/lib/supabase/client'
@@ -302,55 +303,10 @@ function deriveInboxStatus(item: InboxItem): InboxStatus {
}
// ── Skeleton ─────────────────────────────────────────────────
// Mirrors the live layout (top bar + 3-pane card) so the transition from
// the route-level loading.tsx to data-loaded content has no visible reflow.
// Keep in sync with app/(dashboard)/e/[sector]/[slug]/loading.tsx.
// Shared with app/(dashboard)/e/[sector]/[slug]/loading.tsx so the route
// fallback and this client-fetch shell are one silhouette with no reflow.
function WorkspaceSkeleton() {
return (
<div className="h-[calc(100vh-1px)] md:h-full">
<div className="h-full flex flex-col overflow-hidden">
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="hidden md:block h-3 w-56" />
</div>
<Skeleton className="h-8 w-28 shrink-0" />
</header>
<div className="flex-1 grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)_340px] min-h-0">
<aside className="border-b xl:border-b-0 xl:border-r overflow-hidden bg-muted/20 pt-3">
<div className="px-3 pb-3 space-y-2 border-b">
<Skeleton className="h-8 w-full" />
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-10 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
</div>
<ul>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="border-b px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 shrink-0" />
<Skeleton className="h-3.5 flex-1 max-w-[180px]" />
</div>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
</li>
))}
</ul>
</aside>
<main className="overflow-hidden bg-muted/10 hidden xl:block" />
<aside className="border-l overflow-hidden hidden xl:block" />
</div>
</div>
</div>
)
}
const WorkspaceSkeleton = InvoiceInboxSkeleton
// ── Main component ───────────────────────────────────────────