geocrop-platform./apps/nextgen/docs/PERF.md

6.7 KiB

Performance budget

Audience: frontend maintainers and PR reviewers. Scope: the bundle-size budget per role group, how to measure it, and the role-aware lazy-loading design.

This document is the contract that protects the offline-first PWA from back-sliding into a single bloated bundle. Every CI / merge gate that touches client/src/App.tsx or client/src/pages/lazy/* should run a build and verify the budgets below are still met.

1. The lazy-loading architecture

client/src/App.tsx declares 12 role-route groups as React.lazy() imports, one per role that has UI pages:

const AdminRoutes        = React.lazy(() => import('./pages/lazy/admin/index'));
const TeacherRoutes      = React.lazy(() => import('./pages/lazy/teacher/index'));
const StudentRoutes      = React.lazy(() => import('./pages/lazy/student/index'));
const ParentRoutes       = React.lazy(() => import('./pages/lazy/parent/index'));
const HrRoutes           = React.lazy(() => import('./pages/lazy/hr/index'));
const LibrarianRoutes    = React.lazy(() => import('./pages/lazy/librarian/index'));
const BursarRoutes       = React.lazy(() => import('./pages/lazy/bursar/index'));
const PrincipalRoutes    = React.lazy(() => import('./pages/lazy/principal/index'));
const ClubsHeadRoutes    = React.lazy(() => import('./pages/lazy/clubs_head/index'));
const SystemsAdminRoutes = React.lazy(() => import('./pages/lazy/systems_admin/index'));
const NurseRoutes        = React.lazy(() => import('./pages/lazy/nurse/index'));
const DiningStaffRoutes  = React.lazy(() => import('./pages/lazy/dining_staff/index'));

LazyRoleGroup wraps each lazy role in a <Suspense fallback={<LoadingFallback />}> inside a <ProtectedRoute allowedRoles=...>. The LoadingFallback renders a centered spinner without shifting the surrounding layout — the Nav and shell remain in place while the chunk streams in.

client/src/components/Nav.tsx pre-fetches the role chunk on onMouseEnter via prefetchRoleChunk(role). Because the user's intent is observable before they click, a teacher@school.com user hovering the sidebar link triggers the network round-trip in advance — by the time they click, the chunk is usually already in memory.

2. The barrel files

Each client/src/pages/lazy/<role>/index.ts re-exports every page for that role. They are the unit of lazy loading — Vite produces one output chunk per barrel plus shared chunks for any cross-barrel pages (e.g. an <HRManagement> page imported from the admin barrel).

A barrel looks like this (admin example):

export { default as AdminDashboard } from '../../pages/dashboard/AdminDashboard';
export { default as HRManagement }    from '../../pages/admin/HRManagement';
export { default as Students }        from '../../pages/admin/Users';
// …and so on for every admin page

When adding a new admin page, add a line here — the lazy role won't find the page otherwise. Then add it to the <AdminRoutes> route list in App.tsx.

3. The budgets

After cd client && npm run build, the role group's total JS payload (every .js chunk imported transitively from that role's first navigation plus the shared vendor chunks) should sit under the budget below. These are deliberately loose to avoid blocking refactors — the goal is to catch regressions in the 100KB+ range, not forbid incremental growth.

Role Per-role JS budget Vendor share counted?
Admin (school) ≤ 800 KB shared vendor chunks split pro-rata across the page chunks
Teacher ≤ 600 KB same
Student ≤ 500 KB same
Parent ≤ 400 KB same
HR / Bursar / Librarian / Clubs head / Principal (no budget — inherit from Admin / Teacher / Student)

The split-pro-rata method: list every chunk transitively reached from import('./pages/lazy/<role>/index'), sum their kB values. If the sum is above the budget, either split a heavy page into a deeper lazy chunk or move a vendored library out of manualChunks into a per-page import.

4. How to measure

cd client
npm run build                          # default: full bundle
npm run build:analyze                  # vite-bundle-visualizer in browser

build:analyze runs vite build --mode analyze and pipes the output through vite-bundle-visualizer. Open the treemap and look for any of:

  • A single chunk > 200 KB that isn't a vendor chunk — likely a candidate for further lazy splitting.
  • A vendor chunk pulling in a library page-X doesn't need — adjust vite.config.ts manualChunks.
  • A duplicated dependency (e.g. two recharts instances from different lazy paths) — usually a sign of mismatched imports.

5. Manual smoke test

After any change to client/src/App.tsx, vite.config.ts, or client/src/pages/lazy/*:

  1. cd client && npm run build — must succeed with the budget gates above.
  2. cd client && npx playwright test e2e/lazy-load.spec.ts — 4 specs:
    • admin dashboard loads without errors.
    • teacher dashboard loads without errors.
    • student dashboard loads without errors.
    • parent dashboard loads without errors.
  3. Open DevTools → Network → throttling to "Slow 3G", reload /dashboard/student. The spinner should appear within 200ms and the chunk should land within 1s. The Nav and shell should be stable throughout (no layout shift).

6. Common regressions to watch for

Symptom Likely cause
A page chunk explodes to 500+ KB A lazy page added a direct import of a heavy library (e.g. recharts). Move the import inside the page's local render or split it further.
npm run build reports a circular dependency Two barrels re-export each other's pages. Restructure: barrels re-export directly-imported pages, not other barrels.
npm run build fails with "Unexpected closing tag" near a <Modal> A modal migration left mismatched </div> instead of </ModalBody>/</Modal>. See scripts/lint-no-inline-modals.js for a regression-net script.
A role's first navigation takes > 3s on a school LAN The pre-fetch in Nav.tsx was removed, or an env header is blocking the chunk. Re-add onMouseEnter={() => prefetchRoleChunk(role)} to the link.
  • client/src/App.tsx:11-22 — the 12 React.lazy() declarations.
  • client/src/App.tsx:53-67LazyRoleGroup wrapper.
  • client/src/components/LoadingFallback.tsx — spinner fallback.
  • client/src/components/Nav.tsx:332-336, 441, 558 — pre-fetch on hover.
  • client/vite.config.ts:91-102manualChunks (react / zustand / recharts / lucide).
  • client/src/pages/lazy/<role>/index.ts — 12 barrel files; one per active role.
  • client/e2e/lazy-load.spec.ts — 4 smoke specs.