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

136 lines
6.7 KiB
Markdown

# 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):
```ts
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
```bash
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. |
## 7. Related files
- `client/src/App.tsx:11-22` — the 12 `React.lazy()` declarations.
- `client/src/App.tsx:53-67``LazyRoleGroup` 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-102``manualChunks` (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.