feat(frontend): Phase 4 Loading + Perceived Performance — close UX-M1 + FE-M5 + PERF-M1 + P-H3 + partial FE-M3 / P-M2

Closes the Phase 4 batch from cowork/frontend-design-audit.html: skeleton
primitive, route-level lazy splitting + vendor manualChunks, mega-page
split (OnboardingWizard), targeted memoization for dashboard charts,
useTransition for filter-toolbar.

═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed facts from the live repo before implementing (not the audit's
stamped numbers — those drifted):

  • Pre-Phase-4 index-*.js = 1,121,868 B raw / 288,238 B gz
    (audit said 980 KB / 247 KB — drifted UP since the audit was written)
  • React.lazy sites = 1 (CommandPaletteHost from Phase 3); zero route-
    level lazy boundaries before this commit
  • vite.config.ts had NO rollupOptions.output.manualChunks
  • Mega-page LOCs: OnboardingWizard 1043 / CertificateDetailPage 977 /
    SCEPAdminPage 806 / CertificatesPage 812 / ESTAdminPage 646
    (audit said 1033 / 936 / 806 / 751 / 646 — all grew due to Phase 1-3
    additions; still mega)
  • Memoization tally: React.memo 0, useMemo 22, useCallback 5,
    useTransition 0, useDeferredValue 0
  • DashboardPage useQuery sites = 9 (audit said 10 — overcount)
  • OnboardingWizard step structure = 4 step fns (issuer / agent /
    certificate / complete) + StepIndicator + WizardFooter +
    CodeBlock + 2 inline create modals. The audit's "6-way split"
    suggestion = 6 files post-split (shell + indicator/shell helpers
    + 4 step files), which is what this commit ships.

═════════════════════════════ CLOSURES ═══════════════════════════════

UX-M1 — Skeleton primitive (web/src/components/Skeleton.tsx, +6 tests)
  • Four variants: page / table / card / stat
  • Each uses Tailwind animate-pulse on layout-shaped divs so eventual
    content lands without CLS
  • role="status" + aria-busy="true" + aria-label for SR users
  • DataTable.tsx now uses Skeleton variant="table" with columns prop
    instead of the centered "Loading..." spinner — every DataTable
    consumer gets layout-shape-preserving loading without code changes.
    The skeleton sizes the table to the actual column count + adds a
    selectable-column slot when relevant.

FE-M5 + SCALE-H1 — route-level code split + vendor manualChunks
  • main.tsx: every page route except DashboardPage (landing route, kept
    eager) is now React.lazy() + wrapped in <Suspense fallback={
    <Skeleton variant="page" />}> via lazyRoute() helper. 35 lazy
    routes total.
  • OnboardingWizard is also lazy-imported inside DashboardPage —
    keeps its 29 KB step-form code off the dashboard hot path for every
    operator who already dismissed the first-run wizard.
  • vite.config.ts: rollupOptions.output.manualChunks splits
    react+react-dom (132 KB), react-router-dom (24 KB),
    @tanstack/react-query (28 KB), recharts (383 KB!), and lucide-react
    (16 KB) into named vendor chunks. Vite 8 rolldown requires the
    function-shape manualChunks (id) => string; not the Vite-5 object
    shape — confirmed against the actual build error before writing
    the function.

  Bundle profile (raw / gz):
    pre-Phase-4   single index-*.js = 1,121,868 / 288,238
    post-Phase-4  index-*.js        =    91,978 /  25,867   (-92% raw)
                  vendor-react      =   132,821 /  43,113
                  vendor-router     =    23,835 /   8,763
                  vendor-query      =    28,029 /   8,693
                  vendor-icons      =    15,663 /   6,149
                  vendor-recharts   =   382,953 / 110,251   (Dashboard-only)
                  per-route chunks  =    1.4-26 KB raw each

  Non-Dashboard cold load: vendor-react + vendor-router + vendor-query
  + vendor-icons + index + per-route chunk ≈ 95 KB gz first-load.
  Dashboard cold load adds vendor-recharts (110 KB gz) on demand.

  Audit target was <100 KB gz first-load for non-Dashboard routes — hit.

FE-M3 + P-M2 (partial) — OnboardingWizard mega-page split
  • 1043 LOC monolith → src/pages/OnboardingWizard.tsx (100 LOC shell) +
    src/pages/onboarding/{types.ts, StepShell.tsx, IssuerStep.tsx,
    AgentStep.tsx, CertificateStep.tsx, CompleteStep.tsx} (6 files,
    largest = CertificateStep at 504 LOC for the certificate form +
    two inline create-team/create-owner modals it owns).
  • Behavior preserved byte-equivalent — DashboardPage's lazy-import
    path is unchanged because OnboardingWizard.tsx still exists at the
    same location with the same default-export prop shape.
  • CertificateDetailPage / SCEPAdminPage / ESTAdminPage / CertificatesPage
    splits deferred: each is already in its own lazy chunk (the bundle-
    size win is achieved). Splitting them adds maintenance benefit but
    requires careful URL-preservation work (especially CertDetail tab
    routing — /certificates/:id must redirect to /overview to preserve
    deep links). Documented as Phase 4 follow-up; not blocking on this
    closure.

PERF-M1 + P-H3 — memoized dashboard chart panels + useTransition filter
  • src/pages/dashboard/charts.tsx — 4 React.memo()-wrapped chart panels
    (CertsByStatusPieChart, ExpirationTimelineBarChart, JobTrendsLine-
    Chart, IssuanceRateBarChart) + ChartCard + CustomTooltip + shared
    helpers. Pre-Phase-4 these lived as inline JSX in DashboardPage's
    return; any of the 9 useQuery refetches forced all four Recharts
    subtrees to reconcile. Post-Phase-4 each panel only re-renders when
    its specific data prop's reference changes.
  • DashboardPage useMemo wraps pieData + weeklyExpiration so the
    memo'd children's prop-equality check works (without useMemo a
    fresh array on every render defeats the memo).
  • Rules-of-Hooks: useMemo hooks live BEFORE the wizard early-return —
    not after. (First implementation put them after; vitest caught it
    with "Rendered more hooks than during the previous render" — fixed.)
  • useListParams hook now wraps setSearchParams in useTransition so
    URL-resident filter / sort / page updates are marked low-priority.
    React can preempt the result-table reconciliation when the operator
    toggles dropdowns rapidly. Affects every list page that uses the
    hook (CertificatesPage is the main consumer post-Bundle-8).

═══════════════════════════ VERIFICATION ═════════════════════════════

  • npx tsc --noEmit — exits 0
  • Skeleton primitive: 6/6 tests green
  • Component suite (12 files): 137/137 green
  • Auth-page suite (13 files): 130/130 green
  • Dashboard + Onboarding + Certificates + CertificateDetail + Targets
    + Agents + Issuers + Jobs + SCEPAdmin + ESTAdmin: 71/71 green
  • npm run build clean; chunk inventory verified (vendor-react,
    vendor-router, vendor-query, vendor-recharts, vendor-icons emitted
    as named chunks; 35 per-route lazy chunks emitted; index-*.js
    shrunk to 91.66 KB raw / 25.92 KB gz).

═══════════════════════════ RESIDUAL RISK ════════════════════════════

  • Vite 8 + rolldown's manualChunks signature differs from Vite 5;
    upgrading Vite again would re-break this config. Comment in
    vite.config.ts pins the function-shape requirement.
  • CertificateDetailPage / SCEP / EST / CertificatesPage splits remain
    open. Mega-LOC files but already lazy-chunked, so deferring is safe.
  • Recharts ResizeObserver mis-fires when memo'd panels resize at the
    same time the parent re-renders. The audit flagged this; no
    repro observed in vitest but worth monitoring in the demo.
This commit is contained in:
shankar0123
2026-05-14 16:14:24 +00:00
parent 0987e222dd
commit 9ce2d8ca8f
15 changed files with 1709 additions and 1219 deletions
+8 -9
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import Skeleton from './Skeleton';
interface Column<T> {
key: string;
@@ -47,16 +48,14 @@ interface DataTableProps<T> {
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
// Phase 4 closure (UX-M1): swap the centered spinner + "Loading..."
// text — which paints into a tiny vertical span and then jumps to a
// full-height table on resolve, the canonical CLS source — for a
// layout-shape-matching skeleton table sized to the actual column
// count. The eye reads "table loading here" and the eventual data
// lands in the same DOM rectangle with zero reflow.
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-ink-muted">
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
);
return <Skeleton variant="table" columns={columns.length + (selectable ? 1 : 0)} />;
}
if (!data.length) {
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import Skeleton from './Skeleton';
describe('Skeleton', () => {
it('page variant renders PageHeader-shaped band + 4 stat tiles + card', () => {
const { container, getByRole } = render(<Skeleton variant="page" />);
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading content');
expect(container.querySelector('.animate-pulse')).not.toBeNull();
// 4 stat tiles
expect(container.querySelectorAll('.grid > .bg-surface')).toHaveLength(4);
});
it('table variant defaults to 6 rows × 5 cols', () => {
const { container } = render(<Skeleton variant="table" />);
const rows = container.querySelectorAll('tbody tr');
expect(rows).toHaveLength(6);
const cells = rows[0].querySelectorAll('td');
expect(cells).toHaveLength(5);
});
it('table variant respects custom rows + columns', () => {
const { container } = render(<Skeleton variant="table" rows={3} columns={4} />);
expect(container.querySelectorAll('tbody tr')).toHaveLength(3);
expect(container.querySelectorAll('tbody tr:first-child td')).toHaveLength(4);
});
it('card variant renders title-row + 3 prose rows', () => {
const { container } = render(<Skeleton variant="card" />);
// 1 title + 3 prose lines = 4 stripes inside the inner card
const stripes = container.querySelectorAll('.bg-surface > div, .bg-surface .space-y-2 > div');
expect(stripes.length).toBeGreaterThanOrEqual(4);
});
it('stat variant renders label-row + number-row', () => {
const { container, getByRole } = render(<Skeleton variant="stat" />);
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
// 2 stripes
expect(container.querySelectorAll('.bg-surface-border')).toHaveLength(2);
});
it('custom ariaLabel surfaces on the role=status root', () => {
const { getByRole } = render(
<Skeleton variant="card" ariaLabel="Loading certificates" />,
);
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading certificates');
});
});
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Skeleton — Phase 4 closure for UX-M1 (206 isLoading sites render as
// "Loading…" text in PageHeader subtitle → layout shift on every fetch).
//
// Four variants, each shaped to match the page region it stands in for
// so the eventual content lands without CLS:
//
// • page — full-page Suspense fallback used by main.tsx route
// lazy-load boundaries. Includes a PageHeader-shaped
// skeleton + a body grid of card / table skeletons.
// • table — list-page body. 6 rows × 5 cells, header row dimmed.
// Drop into DataTable's isLoading branch (or page-local
// tables that don't go through DataTable yet).
// • card — single content card. One title-row + 3 prose rows.
// Composable inside dashboards / detail pages.
// • stat — KPI tile. One label-row + one large number-row.
// Sized to match DashboardPage's stat panels.
//
// Every variant uses Tailwind's `animate-pulse` on layout-shaped divs
// so the eye reads "content loading here" instead of a flash of empty
// container followed by re-flow when the real content paints.
//
// Accessibility: each variant carries role="status" + aria-busy="true"
// + aria-label so screen-reader users hear "Loading <region>" instead
// of an empty announcement.
interface SkeletonProps {
variant: 'page' | 'table' | 'card' | 'stat';
/** Override default aria-label. Default: "Loading content". */
ariaLabel?: string;
/** Number of rows for the `table` variant. Default 6. */
rows?: number;
/** Number of columns for the `table` variant. Default 5. */
columns?: number;
}
export default function Skeleton({
variant,
ariaLabel = 'Loading content',
rows = 6,
columns = 5,
}: SkeletonProps) {
if (variant === 'page') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
{/* PageHeader-shaped band */}
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
<div>
<div className="h-3 w-32 bg-surface-border rounded mb-2" />
<div className="h-5 w-48 bg-surface-border rounded" />
</div>
<div className="h-9 w-28 bg-surface-border rounded" />
</div>
{/* Body grid: 4 stat tiles + 1 card */}
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-surface border border-surface-border rounded-lg p-4"
>
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
<div className="h-7 w-16 bg-surface-border rounded" />
</div>
))}
</div>
<Card />
</div>
</div>
);
}
if (variant === 'table') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
<table className="w-full">
<thead>
<tr className="border-b border-surface-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="text-left px-4 py-3">
<div className="h-3 w-20 bg-surface-border rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r} className="border-b border-surface-border">
{Array.from({ length: columns }).map((_, c) => (
<td key={c} className="px-4 py-3">
<div
className={
'h-3 bg-surface-border rounded ' +
(c === 0 ? 'w-40' : c === columns - 1 ? 'w-16' : 'w-24')
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (variant === 'card') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
<Card />
</div>
);
}
// variant === 'stat'
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse bg-surface border border-surface-border rounded-lg p-4"
>
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
<div className="h-7 w-16 bg-surface-border rounded" />
</div>
);
}
/** Card sub-shape, shared between `page` and `card` variants. */
function Card() {
return (
<div className="bg-surface border border-surface-border rounded-lg p-6">
<div className="h-4 w-40 bg-surface-border rounded mb-4" />
<div className="space-y-2">
<div className="h-3 w-full bg-surface-border rounded" />
<div className="h-3 w-11/12 bg-surface-border rounded" />
<div className="h-3 w-2/3 bg-surface-border rounded" />
</div>
</div>
);
}