Module Keepalive Performance Fix
Date: 2026-04-28Status: Approved
Scope: AdminDashboard, DoctorDashboard, ReceptionistDashboard, PatientManagement
Problem
Every dashboard module uses{activeView === 'X' && <Component />}. When you navigate away, the && expression becomes false and React unmounts the component — destroying all state, data, and scroll position. On return, it remounts from scratch: loading = true, full API re-fetch, full skeleton animation. Affects ~25 modules in Admin, ~20 in Doctor.
Secondary: PatientManagement fires one checkPatientAccountExists(email) API call per patient in a sequential loop — an N+1 that gets linearly slower as the clinic’s patient list grows.
Solution
1. Keepalive pattern (primary fix)
Replace the&& unmount pattern with lazy-mount + CSS hide:
- A module mounts on first visit only
- On navigation away: wrapped in
<div className="hidden">— component stays alive in the DOM, React never destroys it - On return: the
hiddenclass is removed — data, scroll, and state are exactly where the user left them - Modules that have never been visited are not mounted at all (lazy) — no performance cost for unvisited views
visitedViews Set to each dashboard (initialized with the current activeView). A keepAlive(viewId, element) helper centralises the logic. Replaces every && block in the render output.
settings || settings-security || settings-billing all rendering <SettingsModule>) are handled by passing an array of IDs.
Files changed: AdminDashboard.tsx, DoctorDashboard.tsx, ReceptionistDashboard.tsxModule files changed: none
2. PatientManagement N+1 fix
The sequential per-patient loop callscheckPatientAccountExists(email) N times. The fix: add a single batch endpoint POST /api/v1/protected/patients/accounts/batch-check that accepts an array of emails and returns a Set of patient IDs that have accounts. One round-trip replaces N.
Server: New route handler in server/src/routes/patients.tsClient: Replace the
for loop in PatientManagement.tsx with a single batchCheckPatientAccounts(emails) call
What does NOT change
ModuleErrorBoundarywrappers stay in place around every module- Individual module components: no changes to their fetch logic, state, or props
- Permission checks (
has('appointments.view')etc.) remain on the outer wrapper - The
hiddenTailwind class (display: none) hides the element but does not trigger re-renders or re-fetches — React’s reconciler treats it as a mounted, live subtree
Edge cases
| Case | Behaviour |
|---|---|
| Module crashes (error boundary fires) | ErrorBoundary catches it; keepalive wrapper is unaffected; navigating away and back resets the error boundary via key change if needed |
| User logs out | Dashboards unmount entirely; visitedViews state is destroyed; next login starts fresh |
Module with URL-driven state (e.g. ?tab=receipts) | URL params are still in the address bar; module reads them on activation as before |
| Module refreshes data on tab focus | Existing visibility handlers in modules (e.g. visibilitychange listeners) continue to work normally since the component stays mounted |
Affected files
| File | Change |
|---|---|
ui/src/components/dashboards/AdminDashboard.tsx | Add visitedViews state + keepAlive helper, replace && blocks |
ui/src/components/dashboards/DoctorDashboard.tsx | Same |
ui/src/components/dashboards/ReceptionistDashboard.tsx | Same |
ui/src/components/patients/PatientManagement.tsx | Replace N+1 loop with batch check call |
server/src/routes/patients.ts | Add POST /patients/accounts/batch-check endpoint |

