# 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.
