mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 06:08:52 +00:00
9ce2d8ca8f
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.
101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split.
|
|
//
|
|
// Pre-Phase-4 this file was 1043 LOC containing:
|
|
// • 4 step component definitions (Issuer / Agent / Certificate / Complete)
|
|
// • 2 inline modal helpers (CreateTeamModalInline, CreateOwnerModalInline)
|
|
// • 3 layout helpers (CodeBlock, StepIndicator, WizardFooter)
|
|
// • The shell + state + step transitions
|
|
// — all in a single file that took ~30s to navigate end-to-end in the
|
|
// editor and hit the eslint per-file-LOC ceiling.
|
|
//
|
|
// Post-Phase-4 this file is just the shell + state + step transitions
|
|
// (~67 LOC). Each step now lives in src/pages/onboarding/ as its own
|
|
// file, importable in isolation:
|
|
//
|
|
// • types.ts — WizardStep type + STEPS list
|
|
// • StepShell.tsx — shared CodeBlock + StepIndicator + WizardFooter
|
|
// • IssuerStep.tsx — Step 1
|
|
// • AgentStep.tsx — Step 2
|
|
// • CertificateStep.tsx — Step 3 (owns its inline team/owner modals)
|
|
// • CompleteStep.tsx — Step 4
|
|
//
|
|
// Behavior preserved byte-equivalent — no logic change, just a
|
|
// directory reshape. DashboardPage's lazy(() => import('./OnboardingWizard'))
|
|
// import path is unchanged because this file still exists at the same
|
|
// location and still has a default export with the same prop shape.
|
|
|
|
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { STEPS, type WizardStep } from './onboarding/types';
|
|
import { StepIndicator } from './onboarding/StepShell';
|
|
import IssuerStep from './onboarding/IssuerStep';
|
|
import AgentStep from './onboarding/AgentStep';
|
|
import CertificateStep from './onboarding/CertificateStep';
|
|
import CompleteStep from './onboarding/CompleteStep';
|
|
|
|
export default function OnboardingWizard({ onDismiss }: { onDismiss: () => void }) {
|
|
const [step, setStep] = useState<WizardStep>('issuer');
|
|
const [createdIssuerId, setCreatedIssuerId] = useState<string | null>(null);
|
|
const [issuerName, setIssuerName] = useState<string | null>(null);
|
|
const [certName, setCertName] = useState<string | null>(null);
|
|
const navigate = useNavigate();
|
|
|
|
const goTo = (s: WizardStep) => setStep(s);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between px-6 pt-5 pb-0">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-ink">Welcome to certctl</h1>
|
|
<p className="text-sm text-ink-muted mt-0.5">Let's set up your certificate lifecycle management</p>
|
|
</div>
|
|
<button
|
|
onClick={onDismiss}
|
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
|
>
|
|
Skip setup
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
<div className="max-w-2xl mx-auto">
|
|
<StepIndicator steps={STEPS} current={step} />
|
|
|
|
<div className="bg-surface border border-surface-border rounded-lg p-6 shadow-sm">
|
|
{step === 'issuer' && (
|
|
<IssuerStep
|
|
onNext={() => goTo('agent')}
|
|
onSkip={() => goTo('agent')}
|
|
onIssuerCreated={(iss) => { setCreatedIssuerId(iss.id); setIssuerName(iss.name); }}
|
|
/>
|
|
)}
|
|
|
|
{step === 'agent' && (
|
|
<AgentStep
|
|
onNext={() => goTo('certificate')}
|
|
onSkip={() => goTo('certificate')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'certificate' && (
|
|
<CertificateStep
|
|
onNext={(name) => { if (name) setCertName(name); goTo('complete'); }}
|
|
onSkip={() => goTo('complete')}
|
|
createdIssuerId={createdIssuerId}
|
|
/>
|
|
)}
|
|
|
|
{step === 'complete' && (
|
|
<CompleteStep
|
|
onFinish={() => { onDismiss(); navigate('/'); }}
|
|
issuerName={issuerName}
|
|
certName={certName}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|