From f7e33d03357ddd9a5a71ca003e3ba2bf8fd67461 Mon Sep 17 00:00:00 2001 From: colinislit Date: Tue, 25 Nov 2025 14:05:58 +0100 Subject: [PATCH] perf: optimize menu and navigation response time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add React.memo + useMemo/useCallback to EPD sidebar, header, and tabs - Prevent unnecessary re-renders on pathname changes - Fix context reset causing UI flashing during navigation - Target: <250ms menu response (was ~260ms) Files: epd-sidebar, epd-header, patient-context, intake-tabs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/epd/components/epd-header.tsx | 121 ++++++----- app/epd/components/epd-sidebar.tsx | 195 ++++++++++-------- app/epd/components/patient-context.tsx | 13 +- .../[intakeId]/components/intake-tabs.tsx | 79 ++++--- 4 files changed, 245 insertions(+), 163 deletions(-) diff --git a/app/epd/components/epd-header.tsx b/app/epd/components/epd-header.tsx index 2e195cb..705e9d4 100644 --- a/app/epd/components/epd-header.tsx +++ b/app/epd/components/epd-header.tsx @@ -1,5 +1,5 @@ "use client"; -import React from 'react'; +import React, { useMemo, useCallback, memo } from 'react'; import { Search, MoreVertical, Mic } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import { usePatientContext } from './patient-context'; @@ -37,12 +37,71 @@ function StatusBadge({ status }: { status?: string }) { ); } -export function EPDHeader({ className = "" }: EPDHeaderProps) { +export const EPDHeader = memo(function EPDHeader({ className = "" }: EPDHeaderProps) { const { patient } = usePatientContext(); const router = useRouter(); const pathname = usePathname(); - const handleNewReportClick = () => { + // Memoize patient display data to prevent recalculation on every render + const patientDisplay = useMemo(() => { + if (!patient) return null; + + // Extract patient data + const name = patient.name?.[0]; + const fullName = name + ? [ + ...(name.prefix || []), + ...(name.given || []), + name.family, + ] + .filter(Boolean) + .join(' ') + : ''; + + // Extract status from extension + const statusExtension = (patient as any)?.extension?.find( + (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status' + ); + const status = statusExtension?.valueCode; + + // Extract birth date + const birthDate = patient.birthDate + ? new Date(patient.birthDate).toLocaleDateString('nl-NL', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : null; + + // Extract BSN from identifiers + const bsnIdentifier = patient.identifier?.find( + (id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' || + id.system?.includes('bsn') || + id.type?.coding?.[0]?.code === 'BSN' + ); + const bsn = bsnIdentifier?.value; + + // Extract last modified + const lastModified = patient.meta?.lastUpdated + ? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : null; + + // Check if John Doe + const isJohnDoe = (patient as any)?.extension?.find( + (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe' + )?.valueBoolean; + + return { fullName, status, birthDate, bsn, lastModified, isJohnDoe }; + }, [patient]); + + // Stabilize event handler with useCallback + const handleNewReportClick = useCallback(() => { if (!patient?.id) return; const rapportagePath = `/epd/patients/${patient.id}/rapportage`; const onRapportagePage = pathname?.startsWith(rapportagePath); @@ -58,58 +117,10 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) { } router.push(`${rapportagePath}#rapportage-composer`); - }; + }, [patient?.id, pathname, router]); - // Extract patient data - const name = patient?.name?.[0]; - const fullName = name - ? [ - ...(name.prefix || []), - ...(name.given || []), - name.family, - ] - .filter(Boolean) - .join(' ') - : ''; - - // Extract status from extension - const statusExtension = (patient as any)?.extension?.find( - (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status' - ); - const status = statusExtension?.valueCode; - - // Extract birth date - const birthDate = patient?.birthDate - ? new Date(patient.birthDate).toLocaleDateString('nl-NL', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) - : null; - - // Extract BSN from identifiers - const bsnIdentifier = patient?.identifier?.find( - (id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' || - id.system?.includes('bsn') || - id.type?.coding?.[0]?.code === 'BSN' - ); - const bsn = bsnIdentifier?.value; - - // Extract last modified - const lastModified = patient?.meta?.lastUpdated - ? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - : null; - - // Check if John Doe - const isJohnDoe = (patient as any)?.extension?.find( - (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe' - )?.valueBoolean; + // Destructure memoized values for cleaner JSX + const { fullName, status, birthDate, bsn, lastModified, isJohnDoe } = patientDisplay || {}; return (
@@ -179,4 +190,4 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
); -} +}); diff --git a/app/epd/components/epd-sidebar.tsx b/app/epd/components/epd-sidebar.tsx index 48635ca..c60e2d6 100644 --- a/app/epd/components/epd-sidebar.tsx +++ b/app/epd/components/epd-sidebar.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, memo } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { cn } from '@/lib/utils'; import { Users, Settings, @@ -53,22 +54,119 @@ const level2NavigationItems: NavigationItem[] = [ { id: "rapportage", name: "Rapportage", icon: FileBarChart, href: "/rapportage" }, ]; +// Memoized sidebar item component to prevent unnecessary re-renders +interface SidebarItemProps { + item: NavigationItem; + isActive: boolean; + isCollapsed: boolean; + onClick: () => void; +} + +const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onClick }: SidebarItemProps) { + const Icon = item.icon; + + return ( +
  • + +
    + +
    + + {!isCollapsed && ( +
    + + {item.name} + + {item.badge && ( + + {item.badge} + + )} +
    + )} + + {/* Tooltip for collapsed state */} + {isCollapsed && ( +
    + {item.name} +
    +
    + )} + +
  • + ); +}, (prev, next) => { + // Custom comparison - only re-render if these props change + return prev.item.href === next.item.href && + prev.isActive === next.isActive && + prev.isCollapsed === next.isCollapsed; +}); + export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) { const pathname = usePathname(); const [isOpen, setIsOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); // Context detection: Level 2 if URL contains /patients/[id] - const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/); + const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/); const patientId = isPatientContext ? pathname.split('/')[3] : null; - // Determine which navigation items to show - const navigationItems = isPatientContext - ? level2NavigationItems.map(item => ({ + // Memoize navigation items to prevent recreation on every render + const navigationItems = useMemo(() => { + if (isPatientContext) { + return level2NavigationItems.map(item => ({ ...item, href: `/epd/patients/${patientId}${item.href}` - })) - : level1NavigationItems; + })); + } + return level1NavigationItems; + }, [isPatientContext, patientId]); + + // Stabilize event handlers with useCallback + const toggleSidebar = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + + const toggleCollapse = useCallback(() => { + setIsCollapsed(prev => !prev); + }, []); + + const handleItemClick = useCallback(() => { + if (window.innerWidth < 768) { + setIsOpen(false); + } + }, []); + + // Memoize isActive check function + const getIsActive = useCallback((item: NavigationItem): boolean => { + if (item.id === 'dashboard') { + return pathname === item.href; + } + return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/')); + }, [pathname]); // Auto-open sidebar on desktop useEffect(() => { @@ -85,15 +183,6 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr return () => window.removeEventListener('resize', handleResize); }, []); - const toggleSidebar = () => setIsOpen(!isOpen); - const toggleCollapse = () => setIsCollapsed(!isCollapsed); - - const handleItemClick = () => { - if (window.innerWidth < 768) { - setIsOpen(false); - } - }; - // Get user initials const userInitials = userName ? userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) @@ -182,71 +271,15 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr )} diff --git a/app/epd/components/patient-context.tsx b/app/epd/components/patient-context.tsx index 6b5a29d..7555df9 100644 --- a/app/epd/components/patient-context.tsx +++ b/app/epd/components/patient-context.tsx @@ -29,10 +29,15 @@ export function PatientProvider({ children }: { children: React.ReactNode }) { // Hook to inject patient data into context from nested layouts export function useSetPatient(patient: FHIRPatient | null) { - const { setPatient } = usePatientContext(); + const { patient: currentPatient, setPatient } = usePatientContext(); useEffect(() => { - setPatient(patient); - return () => setPatient(null); // Cleanup when unmounting - }, [patient, setPatient]); + // Only update if patient ID changed - prevents flashing during navigation + if (patient?.id !== currentPatient?.id) { + setPatient(patient); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only depend on ID, not full object + }, [patient?.id, currentPatient?.id, setPatient]); + + // No cleanup - keep patient in context during navigation to prevent flashing } diff --git a/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx b/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx index 75c4999..b51c551 100644 --- a/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx +++ b/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo, useCallback, memo } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; @@ -9,11 +10,48 @@ interface IntakeTabsProps { intakeId: string; } +interface Tab { + name: string; + href: string; + exact?: boolean; +} + +// Memoized tab item component to prevent unnecessary re-renders +interface TabItemProps { + tab: Tab; + isActive: boolean; +} + +const TabItem = memo(function TabItem({ tab, isActive }: TabItemProps) { + return ( + + {tab.name} + + ); +}, (prev, next) => { + // Only re-render if href or active state changes + return prev.tab.href === next.tab.href && prev.isActive === next.isActive; +}); + export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) { const pathname = usePathname(); - const baseUrl = `/epd/patients/${patientId}/intakes/${intakeId}`; - const tabs = [ + // Memoize base URL to prevent recalculation + const baseUrl = useMemo( + () => `/epd/patients/${patientId}/intakes/${intakeId}`, + [patientId, intakeId] + ); + + // Memoize tabs array to prevent recreation on every render + const tabs = useMemo(() => [ { name: 'Algemeen', href: baseUrl, exact: true }, { name: 'Contactmomenten', href: `${baseUrl}/contacts` }, { name: 'Kindcheck', href: `${baseUrl}/kindcheck` }, @@ -23,31 +61,26 @@ export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) { { name: 'ROM', href: `${baseUrl}/rom` }, { name: 'Diagnose', href: `${baseUrl}/diagnosis` }, { name: 'Behandeladvies', href: `${baseUrl}/behandeladvies` }, - ]; + ], [baseUrl]); + + // Memoize isActive check function + const getIsActive = useCallback((tab: Tab) => { + if (tab.exact) { + return pathname === tab.href; + } + return pathname?.startsWith(tab.href); + }, [pathname]); return (
    );