Skip to main content

Module Keepalive Performance Fix

Date: 2026-04-28
Status: 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 hidden class 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
Implementation: Add a 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.
// Track which views have been visited this session
const [visitedViews, setVisitedViews] = useState<Set<string>>(
  () => new Set([activeView])
);
useEffect(() => {
  setVisitedViews(prev => prev.has(activeView) ? prev : new Set([...prev, activeView]));
}, [activeView]);

// Helper — supports single string or array of view IDs (for multi-route modules)
const keepAlive = (viewIds: string | string[], element: React.ReactNode) => {
  const ids = Array.isArray(viewIds) ? viewIds : [viewIds];
  const isActive = ids.some(id => id === activeView) || ids.some(id => activeView.startsWith(id + '/'));
  const wasVisited = ids.some(id => visitedViews.has(id));
  if (!isActive && !wasVisited) return null;
  return <div className={isActive ? undefined : 'hidden'}>{element}</div>;
};
Multi-route modules (e.g. settings || settings-security || settings-billing all rendering <SettingsModule>) are handled by passing an array of IDs. Files changed: AdminDashboard.tsx, DoctorDashboard.tsx, ReceptionistDashboard.tsx
Module files changed: none

2. PatientManagement N+1 fix

The sequential per-patient loop calls checkPatientAccountExists(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.ts
Client: Replace the for loop in PatientManagement.tsx with a single batchCheckPatientAccounts(emails) call

What does NOT change

  • ModuleErrorBoundary wrappers 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 hidden Tailwind 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

CaseBehaviour
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 outDashboards 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 focusExisting visibility handlers in modules (e.g. visibilitychange listeners) continue to work normally since the component stays mounted

Affected files

FileChange
ui/src/components/dashboards/AdminDashboard.tsxAdd visitedViews state + keepAlive helper, replace && blocks
ui/src/components/dashboards/DoctorDashboard.tsxSame
ui/src/components/dashboards/ReceptionistDashboard.tsxSame
ui/src/components/patients/PatientManagement.tsxReplace N+1 loop with batch check call
server/src/routes/patients.tsAdd POST /patients/accounts/batch-check endpoint