From 879c178e6ade4504e3968ce9cf1c018b8960157f Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Sun, 31 May 2026 07:27:18 +0100 Subject: [PATCH] perf(profile): improve profile page performance with memoization and lazy loading --- docs/profile-performance.md | 5 +- .../components/profile/PreferencesSection.tsx | 167 +++++++++++------- .../components/profile/ProfileEditForm.tsx | 33 ++-- src/app/hooks/useProfileUpdate.ts | 11 +- .../profile/components/AchievementsPanel.tsx | 37 ++-- src/app/profile/components/ProfileTabs.tsx | 64 ++++--- src/app/profile/components/SettingsPanel.tsx | 76 +++++--- src/components/profile/PreferencesSection.tsx | 5 +- src/components/profile/ProfileEditForm.tsx | 39 ++-- src/components/shared/ImageUploader.test.tsx | 93 ++++++++++ src/components/shared/ImageUploader.tsx | 48 +++-- src/hooks/useProfileUpdate.tsx | 7 +- 12 files changed, 404 insertions(+), 181 deletions(-) create mode 100644 src/components/shared/ImageUploader.test.tsx diff --git a/docs/profile-performance.md b/docs/profile-performance.md index be157bbc..fe1d7e67 100644 --- a/docs/profile-performance.md +++ b/docs/profile-performance.md @@ -8,6 +8,9 @@ The profile route keeps its static shell server-rendered and limits client JavaS - `src/app/profile/components/ProfileTabs.tsx` owns the small client-side tab state. - The default profile panel renders first, while settings and achievements are split into lazy-loaded tab panels. - Shared profile, preference, and achievement data lives in `src/app/profile/profile-data.ts` to avoid recreating arrays during render. +- Tab buttons, settings switches, and achievement cards are memoized so tab and switch updates touch fewer child components. +- Profile edit callbacks are stable, which keeps memoized children from rerendering only because handler identities changed. +- Avatar previews use temporary object URLs instead of base64 data URLs and revoke them when replaced or unmounted. - Tabs and switches use semantic roles and accessible names so the optimization does not trade away usability. ## Validation @@ -15,5 +18,5 @@ The profile route keeps its static shell server-rendered and limits client JavaS Run the focused regression suite with: ```bash -pnpm vitest run src/app/profile/__tests__/ProfileTabs.test.tsx +pnpm vitest run src/app/profile/__tests__/ProfileTabs.test.tsx src/components/shared/ImageUploader.test.tsx ``` diff --git a/src/app/components/profile/PreferencesSection.tsx b/src/app/components/profile/PreferencesSection.tsx index fa743e6b..e8bb5cd8 100644 --- a/src/app/components/profile/PreferencesSection.tsx +++ b/src/app/components/profile/PreferencesSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { memo, useCallback, useState } from 'react'; interface PreferenceOption { id: string; @@ -44,84 +44,123 @@ const privacyPreferences: PreferenceOption[] = [ }, ]; -export default function PreferencesSection() { - const [notificationSettings, setNotificationSettings] = useState>({ - email_notifications: true, - marketing_emails: false, - course_updates: true, - }); +const defaultNotificationSettings = { + email_notifications: true, + marketing_emails: false, + course_updates: true, +}; - const [privacySettings, setPrivacySettings] = useState>({ - profile_visibility: true, - show_progress: true, - show_achievements: true, - }); +const defaultPrivacySettings = { + profile_visibility: true, + show_progress: true, + show_achievements: true, +}; - const handleNotificationChange = (id: string) => { +interface PreferenceCheckboxProps { + preference: PreferenceOption; + checked: boolean; + onToggle: (id: string) => void; +} + +const PreferenceCheckbox = memo(function PreferenceCheckbox({ + preference, + checked, + onToggle, +}: PreferenceCheckboxProps) { + const handleChange = useCallback(() => { + onToggle(preference.id); + }, [onToggle, preference.id]); + + return ( +
+
+ +
+
+ +

{preference.description}

+
+
+ ); +}); + +interface PreferenceGroupProps { + title: string; + preferences: PreferenceOption[]; + settings: Record; + onToggle: (id: string) => void; +} + +const PreferenceGroup = memo(function PreferenceGroup({ + title, + preferences, + settings, + onToggle, +}: PreferenceGroupProps) { + return ( +
+

{title}

+
+ {preferences.map((preference) => ( + + ))} +
+
+ ); +}); + +function PreferencesSection() { + const [notificationSettings, setNotificationSettings] = useState>( + defaultNotificationSettings, + ); + + const [privacySettings, setPrivacySettings] = + useState>(defaultPrivacySettings); + + const handleNotificationChange = useCallback((id: string) => { setNotificationSettings((prev) => ({ ...prev, [id]: !prev[id], })); - }; + }, []); - const handlePrivacyChange = (id: string) => { + const handlePrivacyChange = useCallback((id: string) => { setPrivacySettings((prev) => ({ ...prev, [id]: !prev[id], })); - }; + }, []); return (
-
-

Notification Preferences

-
- {notificationPreferences.map((preference) => ( -
-
- handleNotificationChange(preference.id)} - className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" - /> -
-
- -

{preference.description}

-
-
- ))} -
-
+ -
-

Privacy Settings

-
- {privacyPreferences.map((preference) => ( -
-
- handlePrivacyChange(preference.id)} - className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" - /> -
-
- -

{preference.description}

-
-
- ))} -
-
+
); } + +export default memo(PreferencesSection); diff --git a/src/app/components/profile/ProfileEditForm.tsx b/src/app/components/profile/ProfileEditForm.tsx index b5f3a946..b3f8dd0b 100644 --- a/src/app/components/profile/ProfileEditForm.tsx +++ b/src/app/components/profile/ProfileEditForm.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -23,6 +24,18 @@ const profileSchema = z.object({ type ProfileFormData = z.infer; +const profileFormDefaults: ProfileFormData = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + bio: 'Software developer and tech enthusiast', + location: 'New York, USA', + website: 'https://johndoe.com', + twitter: '@johndoe', + github: 'johndoe', + linkedin: 'johndoe', +}; + export default function ProfileEditForm() { const { updateProfile, isLoading } = useProfileUpdate(); @@ -32,27 +45,19 @@ export default function ProfileEditForm() { formState: { errors }, } = useForm({ resolver: zodResolver(profileSchema), - defaultValues: { - firstName: 'John', - lastName: 'Doe', - email: 'john@example.com', - bio: 'Software developer and tech enthusiast', - location: 'New York, USA', - website: 'https://johndoe.com', - twitter: '@johndoe', - github: 'johndoe', - linkedin: 'johndoe', - }, + defaultValues: profileFormDefaults, }); - const onSubmit = async (data: ProfileFormData) => { + const onSubmit = useCallback(async (data: ProfileFormData) => { try { await updateProfile(data); toast.success('Profile updated successfully!'); } catch { toast.error('Failed to update profile. Please try again.'); } - }; + }, [updateProfile]); + + const handleImageSelect = useCallback(() => {}, []); return (
@@ -60,7 +65,7 @@ export default function ProfileEditForm() {

Personal Information

- {}} /> +
diff --git a/src/app/hooks/useProfileUpdate.ts b/src/app/hooks/useProfileUpdate.ts index abd0e662..6d85f606 100644 --- a/src/app/hooks/useProfileUpdate.ts +++ b/src/app/hooks/useProfileUpdate.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; interface ProfileData { firstName: string; @@ -15,7 +15,7 @@ interface ProfileData { export function useProfileUpdate() { const [isLoading, setIsLoading] = useState(false); - const updateProfile = async (data: ProfileData) => { + const updateProfile = useCallback(async (data: ProfileData) => { setIsLoading(true); try { // TODO: Replace with actual API call @@ -41,10 +41,7 @@ export function useProfileUpdate() { } finally { setIsLoading(false); } - }; + }, []); - return { - updateProfile, - isLoading, - }; + return useMemo(() => ({ updateProfile, isLoading }), [updateProfile, isLoading]); } diff --git a/src/app/profile/components/AchievementsPanel.tsx b/src/app/profile/components/AchievementsPanel.tsx index 75faf61c..842b71eb 100644 --- a/src/app/profile/components/AchievementsPanel.tsx +++ b/src/app/profile/components/AchievementsPanel.tsx @@ -1,6 +1,27 @@ +'use client'; + +import { memo } from 'react'; +import type { Achievement } from '../profile-data'; import { achievements } from '../profile-data'; -export default function AchievementsPanel() { +interface AchievementCardProps { + achievement: Achievement; +} + +const AchievementCard = memo(function AchievementCard({ achievement }: AchievementCardProps) { + return ( +
+ +

{achievement.title}

+

{achievement.description}

+

{achievement.earnedAt}

+
+ ); +}); + +function AchievementsPanel() { return (
{achievements.map((achievement) => ( -
- -

{achievement.title}

-

{achievement.description}

-

{achievement.earnedAt}

-
+ ))}
); } + +export default memo(AchievementsPanel); diff --git a/src/app/profile/components/ProfileTabs.tsx b/src/app/profile/components/ProfileTabs.tsx index c5f8732b..8904ffc5 100644 --- a/src/app/profile/components/ProfileTabs.tsx +++ b/src/app/profile/components/ProfileTabs.tsx @@ -1,7 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; -import { useCallback, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import type { ProfileTabId } from '../profile-data'; import { profileTabs } from '../profile-data'; import ProfileInfoPanel from './ProfileInfoPanel'; @@ -15,6 +15,39 @@ const AchievementsPanel = dynamic(() => import('./AchievementsPanel'), { loading: () => , }); +interface ProfileTabButtonProps { + tab: { id: ProfileTabId; label: string }; + isActive: boolean; + onSelect: (tabId: ProfileTabId) => void; +} + +const ProfileTabButton = memo(function ProfileTabButton({ + tab, + isActive, + onSelect, +}: ProfileTabButtonProps) { + const handleClick = useCallback(() => { + onSelect(tab.id); + }, [onSelect, tab.id]); + + return ( + + ); +}); + export default function ProfileTabs() { const [activeTab, setActiveTab] = useState('profile'); @@ -25,27 +58,14 @@ export default function ProfileTabs() { return ( <>
- {profileTabs.map((tab) => { - const isActive = activeTab === tab.id; - - return ( - - ); - })} + {profileTabs.map((tab) => ( + + ))}
{activeTab === 'profile' && } diff --git a/src/app/profile/components/SettingsPanel.tsx b/src/app/profile/components/SettingsPanel.tsx index 7d12cd3b..9382d2fc 100644 --- a/src/app/profile/components/SettingsPanel.tsx +++ b/src/app/profile/components/SettingsPanel.tsx @@ -1,14 +1,54 @@ 'use client'; import { memo, useCallback, useState } from 'react'; +import type { PreferenceOption } from '../profile-data'; import { settingsPreferences } from '../profile-data'; -function SettingsPanel() { - const [settings, setSettings] = useState>(() => - Object.fromEntries( - settingsPreferences.map((preference) => [preference.id, preference.enabled]), - ), +const defaultSettings = Object.fromEntries( + settingsPreferences.map((preference) => [preference.id, preference.enabled]), +) as Record; + +interface PreferenceSwitchProps { + preference: PreferenceOption; + checked: boolean; + onToggle: (id: string) => void; +} + +const PreferenceSwitch = memo(function PreferenceSwitch({ + preference, + checked, + onToggle, +}: PreferenceSwitchProps) { + const handleChange = useCallback(() => { + onToggle(preference.id); + }, [onToggle, preference.id]); + + return ( +
+
+

{preference.label}

+

+ {preference.description} +

+
+ +
); +}); + +function SettingsPanel() { + const [settings, setSettings] = useState>(defaultSettings); const handleToggle = useCallback((id: string) => { setSettings((currentSettings) => ({ @@ -28,26 +68,12 @@ function SettingsPanel() {
{settingsPreferences.map((preference) => ( -
-
-

{preference.label}

-

- {preference.description} -

-
- -
+ ))}
diff --git a/src/components/profile/PreferencesSection.tsx b/src/components/profile/PreferencesSection.tsx index 56922c3f..bd9ab63d 100644 --- a/src/components/profile/PreferencesSection.tsx +++ b/src/components/profile/PreferencesSection.tsx @@ -1,8 +1,9 @@ 'use client'; +import { memo } from 'react'; import { useFormContext } from 'react-hook-form'; -export default function PreferencesSection() { +function PreferencesSection() { const { register } = useFormContext(); return ( @@ -86,3 +87,5 @@ export default function PreferencesSection() { ); } + +export default memo(PreferencesSection); diff --git a/src/components/profile/ProfileEditForm.tsx b/src/components/profile/ProfileEditForm.tsx index 3ebe7807..0e08898f 100644 --- a/src/components/profile/ProfileEditForm.tsx +++ b/src/components/profile/ProfileEditForm.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback, useMemo } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -29,33 +30,41 @@ type ProfileFormData = z.infer; export default function ProfileEditForm() { const { updateProfile, isLoading } = useProfileUpdate(); - const user = useStore((state) => state.user); + const userId = useStore((state) => state.user.id); + const userName = useStore((state) => state.user.name); + const notificationsEnabled = useStore((state) => state.user.preferences.notifications); + const theme = useStore((state) => state.user.preferences.theme); + const prefetching = useStore((state) => state.user.preferences.prefetching); const setPreferences = useStore((state) => state.setPreferences); const setUser = useStore((state) => state.setUser); - const methods = useForm({ - resolver: zodResolver(profileSchema), - defaultValues: { - name: user.name || 'John Doe', - email: user.id ? 'user@example.com' : 'john@example.com', // Placeholder if no user + const defaultValues = useMemo( + () => ({ + name: userName || 'John Doe', + email: userId ? 'user@example.com' : 'john@example.com', bio: 'Lifelong learner and enthusiast.', notifications: { - email: user.preferences.notifications, + email: notificationsEnabled, push: false, }, - theme: user.preferences.theme, - prefetching: user.preferences.prefetching, - }, + theme, + prefetching, + }), + [notificationsEnabled, prefetching, theme, userId, userName], + ); + + const methods = useForm({ + resolver: zodResolver(profileSchema), + defaultValues, }); const { handleSubmit, - register, formState: { errors }, setValue, } = methods; - const onSubmit = async (data: ProfileFormData) => { + const onSubmit = useCallback(async (data: ProfileFormData) => { const success = await updateProfile(data); if (success) { setUser({ name: data.name }); @@ -65,11 +74,11 @@ export default function ProfileEditForm() { prefetching: data.prefetching, }); } - }; + }, [setPreferences, setUser, updateProfile]); - const handleImageSelect = (file: File) => { + const handleImageSelect = useCallback((file: File) => { setValue('avatar', file); - }; + }, [setValue]); return (
diff --git a/src/components/shared/ImageUploader.test.tsx b/src/components/shared/ImageUploader.test.tsx new file mode 100644 index 00000000..740c0ed9 --- /dev/null +++ b/src/components/shared/ImageUploader.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ImageUploader from './ImageUploader'; + +vi.mock('next/image', () => ({ + default: ({ + src, + alt, + fill, + unoptimized, + ...props + }: React.ImgHTMLAttributes & { + src: string; + alt: string; + fill?: boolean; + unoptimized?: boolean; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +describe('ImageUploader', () => { + const createObjectURL = vi.fn(); + const revokeObjectURL = vi.fn(); + + beforeEach(() => { + createObjectURL.mockReset(); + revokeObjectURL.mockReset(); + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL, + }); + }); + + it('uses an object URL for selected avatar previews and revokes it on unmount', () => { + createObjectURL.mockReturnValue('blob:profile-preview'); + const onImageSelect = vi.fn(); + + const { container, unmount } = render(); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + + fireEvent.change(fileInput, { target: { files: [avatar] } }); + + expect(createObjectURL).toHaveBeenCalledWith(avatar); + expect(onImageSelect).toHaveBeenCalledWith(avatar); + expect(screen.getByRole('img', { name: 'Profile Preview' })).toHaveAttribute( + 'src', + 'blob:profile-preview', + ); + expect(screen.getByRole('img', { name: 'Profile Preview' })).toHaveAttribute( + 'data-unoptimized', + 'true', + ); + + unmount(); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:profile-preview'); + }); + + it('revokes the previous object URL when the selected avatar changes', () => { + createObjectURL.mockReturnValueOnce('blob:first-preview').mockReturnValueOnce('blob:next-preview'); + + const { container, unmount } = render(); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + fireEvent.change(fileInput, { + target: { files: [new File(['first'], 'first.png', { type: 'image/png' })] }, + }); + fireEvent.change(fileInput, { + target: { files: [new File(['next'], 'next.png', { type: 'image/png' })] }, + }); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:first-preview'); + + unmount(); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:next-preview'); + }); +}); diff --git a/src/components/shared/ImageUploader.tsx b/src/components/shared/ImageUploader.tsx index 6fa336b0..d265619b 100644 --- a/src/components/shared/ImageUploader.tsx +++ b/src/components/shared/ImageUploader.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useRef, ChangeEvent } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import type { ChangeEvent } from 'react'; import Image from 'next/image'; interface ImageUploaderProps { @@ -9,38 +10,49 @@ interface ImageUploaderProps { className?: string; } -export default function ImageUploader({ +function ImageUploader({ onImageSelect, initialImageUrl, className = '', }: ImageUploaderProps) { const [previewUrl, setPreviewUrl] = useState(initialImageUrl || null); const fileInputRef = useRef(null); + const objectUrlRef = useRef(null); - const handleFileChange = (event: ChangeEvent) => { + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + }; + }, []); + + const handleFileChange = useCallback((event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { - // Create local preview - const reader = new FileReader(); - reader.onloadend = () => { - setPreviewUrl(reader.result as string); - }; - reader.readAsDataURL(file); + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + + const objectUrl = URL.createObjectURL(file); + objectUrlRef.current = objectUrl; + setPreviewUrl(objectUrl); - // Pass file to parent onImageSelect(file); } - }; + }, [onImageSelect]); - const handleClick = () => { + const handleClick = useCallback(() => { fileInputRef.current?.click(); - }; + }, []); return (
-
{previewUrl ? ( Profile Preview ) : ( @@ -75,7 +87,7 @@ export default function ImageUploader({ Change
-
+ ); } + +export default memo(ImageUploader); diff --git a/src/hooks/useProfileUpdate.tsx b/src/hooks/useProfileUpdate.tsx index 3470a9bc..734ee45d 100644 --- a/src/hooks/useProfileUpdate.tsx +++ b/src/hooks/useProfileUpdate.tsx @@ -1,5 +1,6 @@ import { useErrorHandling } from './useErrorHandling'; import { useToast } from '@/context/ToastContext'; +import { useCallback, useMemo } from 'react'; interface ProfileData { name?: string; @@ -18,7 +19,7 @@ export function useProfileUpdate() { const { execute, isLoading } = useErrorHandling(); const { success } = useToast(); - const updateProfile = async (data: ProfileData) => { + const updateProfile = useCallback(async (data: ProfileData) => { const result = await execute(async () => { // Simulate API call await new Promise((resolve, reject) => { @@ -35,7 +36,7 @@ export function useProfileUpdate() { return true; } return false; - }; + }, [execute, success]); - return { updateProfile, isLoading }; + return useMemo(() => ({ updateProfile, isLoading }), [updateProfile, isLoading]); }