136 lines
6.7 KiB
Markdown
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.
|