mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 14:08:51 +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.
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
// Bundle-8 / Audit M-010:
|
|
//
|
|
// Single hook for filter / sort / pagination state on every list page.
|
|
// Pre-Bundle-8, list pages stored these in local `useState` (see
|
|
// CertificatesPage:417 `const [page, setPage] = useState(1)`), which
|
|
// broke deep-linking and browser-back consistency. The DashboardPage
|
|
// already used `useSearchParams` directly; this hook canonicalises that
|
|
// pattern so the rest of the list pages can migrate mechanically.
|
|
//
|
|
// URL contract:
|
|
//
|
|
// ?page=2&page_size=25&sort=-created_at&filter[status]=active&filter[team_id]=t-platform
|
|
//
|
|
// Defaults are applied client-side — they do NOT appear in the URL when
|
|
// the user hasn't customised them, keeping shareable URLs short.
|
|
//
|
|
// Bundle-8 ships the hook + 1 demonstration migration (CertificatesPage)
|
|
// per the bundle prompt. The remaining list pages (IssuersPage,
|
|
// TargetsPage, AgentsPage, PoliciesPage, ProfilesPage, OwnersPage,
|
|
// TeamsPage, AgentGroupsPage, AuditEventsPage, NotificationsPage,
|
|
// JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a
|
|
// follow-up bundle — tracked as new ID `M-029`.
|
|
|
|
import { useCallback, useMemo, useTransition } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
export interface ListParams {
|
|
/** Current page (1-indexed). Default: 1. */
|
|
page: number;
|
|
/** Page size. Default: 25. */
|
|
pageSize: number;
|
|
/** Sort key (e.g., `created_at`, `-name` for descending). Default: `''` (no sort). */
|
|
sort: string;
|
|
/** Filter map keyed by filter name (e.g. `{status: 'active', team_id: 't-platform'}`). */
|
|
filters: Record<string, string>;
|
|
}
|
|
|
|
export interface ListParamsControls {
|
|
params: ListParams;
|
|
setPage: (page: number) => void;
|
|
setPageSize: (pageSize: number) => void;
|
|
setSort: (sort: string) => void;
|
|
setFilter: (key: string, value: string | null) => void;
|
|
resetParams: () => void;
|
|
}
|
|
|
|
const DEFAULT_PAGE = 1;
|
|
const DEFAULT_PAGE_SIZE = 25;
|
|
|
|
/**
|
|
* Read filter/sort/pagination state from URL search params, with helpers to
|
|
* update the URL via `setSearchParams({ replace: true })` (preserves
|
|
* browser-back history without flooding it with intermediate states).
|
|
*
|
|
* @param defaults - per-page overrides for the global defaults above
|
|
*/
|
|
export function useListParams(defaults?: Partial<ListParams>): ListParamsControls {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
// Phase 4 closure (PERF-M1): mark URL-resident filter / sort / page
|
|
// updates as a transition so React can preempt the result-table
|
|
// reconciliation when the operator interacts with the toolbar (e.g.
|
|
// rapidly toggling dropdowns while a 50-row table is still rendering
|
|
// the previous result). useTransition keeps the dropdown UI snappy
|
|
// even when the result render is expensive.
|
|
const [, startTransition] = useTransition();
|
|
|
|
const params = useMemo<ListParams>(() => {
|
|
const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE);
|
|
const pageSize = parsePositiveInt(
|
|
searchParams.get('page_size'),
|
|
defaults?.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
);
|
|
const sort = searchParams.get('sort') ?? defaults?.sort ?? '';
|
|
const filters: Record<string, string> = { ...(defaults?.filters ?? {}) };
|
|
searchParams.forEach((value, key) => {
|
|
const m = /^filter\[(.+)\]$/.exec(key);
|
|
if (m && value) {
|
|
filters[m[1]] = value;
|
|
}
|
|
});
|
|
return { page, pageSize, sort, filters };
|
|
}, [searchParams, defaults]);
|
|
|
|
const updateParam = useCallback(
|
|
(key: string, value: string | null) => {
|
|
const next = new URLSearchParams(searchParams);
|
|
if (value === null || value === '') {
|
|
next.delete(key);
|
|
} else {
|
|
next.set(key, value);
|
|
}
|
|
// Bundle-8: filter / sort changes reset page to 1 (the existing
|
|
// CertificatesPage behaviour we're preserving). Only the page
|
|
// setter is allowed to set page > 1 directly.
|
|
if (key !== 'page') {
|
|
next.delete('page');
|
|
}
|
|
// startTransition lets React mark the downstream table reconcile
|
|
// as low-priority work — urgent updates (input typing, button
|
|
// hover) can preempt. The URL itself still updates immediately
|
|
// because setSearchParams calls history.replaceState synchronously;
|
|
// only the React-tree reconciliation is deferred.
|
|
startTransition(() => {
|
|
setSearchParams(next, { replace: true });
|
|
});
|
|
},
|
|
[searchParams, setSearchParams],
|
|
);
|
|
|
|
return {
|
|
params,
|
|
setPage: (page) => updateParam('page', page > 1 ? String(page) : null),
|
|
setPageSize: (size) => updateParam('page_size', size !== DEFAULT_PAGE_SIZE ? String(size) : null),
|
|
setSort: (sort) => updateParam('sort', sort || null),
|
|
setFilter: (key, value) => updateParam(`filter[${key}]`, value),
|
|
resetParams: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
|
};
|
|
}
|
|
|
|
function parsePositiveInt(raw: string | null, fallback: number): number {
|
|
if (!raw) return fallback;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|