mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:01:36 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user