# Frontend Performance Optimization Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Reduce initial bundle size by ~3-4 MB and improve FCP/LCP by code-splitting heavy libraries and lazy-loading routes. **Architecture:** Five independent optimizations: (1) dynamic maplibre imports, (2) lazy TipTap components, (3) dynamic dicebear style loading, (4) lazy route loading, (5) React.memo on hot-path components. Each can be tested independently. **Tech Stack:** React, React Router v7, Vite, MUI, maplibre-gl, TipTap, @dicebear/core --- ### Task 1: Code-split maplibre-gl — remove global CSS, dynamic import in AddressMapPicker **Files:** - Modify: `client/src/main.tsx` — remove maplibre-gl CSS import - Modify: `client/src/features/address-map-picker/ui/AddressMapPicker.tsx` — convert to lazy component with dynamic CSS+JS import - [ ] **Step 1: Remove maplibre-gl CSS from main.tsx** ```tsx // client/src/main.tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app/App' import '@/app/styles/global.css' // REMOVE: import 'maplibre-gl/dist/maplibre-gl.css' import { readStoredToken, tokenSet } from '@/shared/model/auth' tokenSet(readStoredToken()) createRoot(document.getElementById('root')!).render( , ) ``` - [ ] **Step 2: Run dev server and verify app still starts** Run: `cd client && npm run dev` Expected: No errors in console about missing maplibre CSS (it won't be loaded yet, that's fine) - [ ] **Step 3: Create lazy map component wrapper for AddressMapPicker** The AddressMapPicker component uses `maplibre-gl` and `react-map-gl/maplibre` with static imports. We need to: 1. Move the map rendering into a separate component that dynamically imports maplibre 2. Load the CSS dynamically before rendering the map 3. Show a skeleton while loading Create a new file `client/src/features/address-map-picker/ui/MapPickerMap.tsx`: ```tsx import { useEffect, useRef, useState } from 'react' import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined' import IconButton from '@mui/material/IconButton' import Tooltip from '@mui/material/Tooltip' import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre' import type * as maplibregl from 'maplibre-gl' import { reverseGeocode } from '../api/map-geocoding' import type { LatLng } from '../model/types' let maplibreglPromise: Promise | null = null function loadMaplibre() { if (!maplibreglPromise) { maplibreglPromise = Promise.all([ import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css'), ]).then(([mod]) => mod) } return maplibreglPromise } type MapPickerMapProps = { value: { lat: number; lng: number } | null onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void center: { lat: number; lng: number } } export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) { const mapRef = useRef(null) const [maplibre, setMaplibre] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { let cancelled = false loadMaplibre().then((mod) => { if (!cancelled) { setMaplibre(mod) setLoading(false) } }) return () => { cancelled = true } }, []) const pick = async (pos: LatLng) => { onChange({ lat: pos.lat, lng: pos.lng }) try { const addr = await reverseGeocode(pos) if (addr) { onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr }) } } catch { // ignore } } if (loading || !maplibre) { return ( ) } return ( { const { lng, lat } = e.lngLat void pick({ lat, lng }) }} > {value && ( { const { lng, lat } = e.lngLat void pick({ lat, lng }) }} anchor="bottom" > )} { if (!('geolocation' in navigator)) return navigator.geolocation.getCurrentPosition( (pos) => { const lat = pos.coords.latitude const lng = pos.coords.longitude mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 }) void pick({ lat, lng }) }, () => {}, { enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 }, ) }} sx={{ position: 'absolute', top: 10, right: 10, bgcolor: 'background.paper', border: 1, borderColor: 'divider', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' }, }} aria-label="Моё местоположение" > ) } ``` - [ ] **Step 4: Update AddressMapPicker to use the lazy map component** ```tsx // client/src/features/address-map-picker/ui/AddressMapPicker.tsx import { useEffect, useMemo, useRef, useState } from 'react' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import CircularProgress from '@mui/material/CircularProgress' import List from '@mui/material/List' import ListItemButton from '@mui/material/ListItemButton' import ListItemText from '@mui/material/ListItemText' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { reverseGeocode, searchPlaces } from '../api/map-geocoding' import type { LatLng, NominatimItem } from '../model/types' import { MapPickerMap } from './MapPickerMap' export function AddressMapPicker(props: { value: { lat: number; lng: number } | null onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void }) { const { value, onChange } = props const [q, setQ] = useState('') const [searching, setSearching] = useState(false) const [results, setResults] = useState([]) const [hint, setHint] = useState(null) const abortRef = useRef(null) const lastQueryRef = useRef('') const lastRequestAtRef = useRef(0) const qTrimmed = q.trim() const visibleResults = qTrimmed.length >= 3 ? results : [] const center = useMemo(() => { if (value) return { lat: value.lat, lng: value.lng } return { lat: 55.751244, lng: 37.618423 } }, [value]) const pick = async (pos: LatLng) => { setHint(null) onChange({ lat: pos.lat, lng: pos.lng }) try { const addr = await reverseGeocode(pos) if (addr) { setHint(addr) onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr }) } } catch { // ignore } } useEffect(() => { const s = qTrimmed if (s.length < 3) return const t = window.setTimeout(async () => { const now = Date.now() if (now - lastRequestAtRef.current < 900) return if (s === lastQueryRef.current) return lastQueryRef.current = s lastRequestAtRef.current = now abortRef.current?.abort() const ac = new AbortController() abortRef.current = ac setSearching(true) try { setResults(await searchPlaces(s, ac.signal)) } catch (e) { if ((e as { name?: string })?.name !== 'AbortError') { setResults([]) } } finally { setSearching(false) } }, 450) return () => { window.clearTimeout(t) } }, [qTrimmed]) return ( Выбор на карте setQ(e.target.value)} fullWidth /> {visibleResults.length > 0 && ( {visibleResults.map((r) => ( { const lat = Number(r.lat) const lng = Number(r.lon) if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void pick({ lat, lng }) }} > ))} )} {hint && ( Подсказка адреса: {hint} )} ) } ``` - [ ] **Step 5: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/main.tsx client/src/features/address-map-picker/ui/AddressMapPicker.tsx client/src/features/address-map-picker/ui/MapPickerMap.tsx && git commit -m "perf: code-split maplibre-gl in AddressMapPicker" ``` --- ### Task 2: Code-split maplibre-gl in AboutPage **Files:** - Modify: `client/src/pages/about/ui/AboutPage.tsx` — dynamic maplibre import - [ ] **Step 1: Create lazy map component for AboutPage** Create `client/src/pages/about/ui/AboutMap.tsx`: ```tsx import { useEffect, useState } from 'react' import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' import Map, { Marker } from 'react-map-gl/maplibre' import type * as maplibregl from 'maplibre-gl' let maplibreglPromise: Promise | null = null function loadMaplibre() { if (!maplibreglPromise) { maplibreglPromise = Promise.all([ import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css'), ]).then(([mod]) => mod) } return maplibreglPromise } const rasterStyle = { version: 8 as const, sources: { osm: { type: 'raster' as const, tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', }, }, layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }], } type AboutMapProps = { lat: number lng: number } export function AboutMap({ lat, lng }: AboutMapProps) { const [maplibre, setMaplibre] = useState(null) useEffect(() => { let cancelled = false loadMaplibre().then((mod) => { if (!cancelled) setMaplibre(mod) }) return () => { cancelled = true } }, []) if (!maplibre) { return ( ) } return ( ) } ``` - [ ] **Step 2: Update AboutPage to use AboutMap** ```tsx // client/src/pages/about/ui/AboutPage.tsx import Box from '@mui/material/Box' import Link from '@mui/material/Link' import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config' import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point' import { usePageTitle } from '@/shared/lib/use-page-title' import { AboutMap } from './AboutMap' export function AboutPage() { usePageTitle('О нас') const { lat, lng } = PICKUP_COORDINATES return ( О нас Магазин изделий ручной работы. Мы отвечаем за качество и сроки изготовления всего, что вы видите в каталоге. Контакты Email:{' '} {STORE_EMAIL} Телефон:{' '} {STORE_PHONE} ВКонтакте Забрать заказ можно по адресу самовывоза: {PICKUP_ADDRESS_FULL} Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче. ) } ``` - [ ] **Step 3: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/pages/about/ui/AboutPage.tsx client/src/pages/about/ui/AboutMap.tsx && git commit -m "perf: code-split maplibre-gl in AboutPage" ``` --- ### Task 3: Code-split TipTap — lazy RichText components **Files:** - Create: `client/src/shared/ui/RichTextMessageContent.lazy.tsx` - Create: `client/src/shared/ui/RichTextMessageEditor.lazy.tsx` - Modify: All files importing RichTextMessageContent/RichTextMessageEditor — update imports - [ ] **Step 1: Create lazy wrapper for RichTextMessageContent** ```tsx // client/src/shared/ui/RichTextMessageContent.lazy.tsx import { lazy, Suspense } from 'react' import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' const RichTextMessageContentImpl = lazy(() => import('./RichTextMessageContent').then((m) => ({ default: m.RichTextMessageContent })), ) type RichTextMessageContentProps = { value: string tone?: 'default' | 'review' | 'chat' } export function RichTextMessageContent(props: RichTextMessageContentProps) { return ( } > ) } ``` - [ ] **Step 2: Create lazy wrapper for RichTextMessageEditor** ```tsx // client/src/shared/ui/RichTextMessageEditor.lazy.tsx import { lazy, Suspense } from 'react' import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' const RichTextMessageEditorImpl = lazy(() => import('./RichTextMessageEditor').then((m) => ({ default: m.RichTextMessageEditor })), ) type RichTextMessageEditorProps = { value: string onChange: (next: string) => void placeholder?: string disabled?: boolean } export function RichTextMessageEditor(props: RichTextMessageEditorProps) { return ( } > ) } ``` - [ ] **Step 3: Update all imports to use lazy versions** In each of these files, change the import path: ``` # Files to update (change import path): client/src/pages/me/ui/sections/MessagesPage.tsx client/src/features/product-review/ui/ProductReviewsList.tsx client/src/widgets/reviews-block/ui/ReviewsBlock.tsx client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/features/order-chat/ui/OrderChat.tsx client/src/features/product-review/ui/ReviewDialog.tsx client/src/shared/ui/OrderMessageBody.tsx client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx ``` Change in each file: ```diff - import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' + import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' ``` ```diff - import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' + import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy' ``` Use this command to update all files: ```bash cd /mnt/d/my_projects/shop/client/src find . -name 'MessagesPage.tsx' -o -name 'ProductReviewsList.tsx' -o -name 'ReviewsBlock.tsx' -o -name 'OrderDetailContent.tsx' -o -name 'OrderChat.tsx' -o -name 'ReviewDialog.tsx' -o -name 'OrderMessageBody.tsx' -o -name 'AdminReviewsPage.tsx' | while read f; do sed -i "s|from '@/shared/ui/RichTextMessageContent'|from '@/shared/ui/RichTextMessageContent.lazy'|" "$f" sed -i "s|from '@/shared/ui/RichTextMessageEditor'|from '@/shared/ui/RichTextMessageEditor.lazy'|" "$f" done ``` - [ ] **Step 4: Run lint to verify no import errors** Run: `cd client && npm run lint` Expected: No errors related to RichText imports - [ ] **Step 5: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/shared/ui/RichTextMessageContent.lazy.tsx client/src/shared/ui/RichTextMessageEditor.lazy.tsx client/src/pages/me/ui/sections/MessagesPage.tsx client/src/features/product-review/ui/ProductReviewsList.tsx client/src/widgets/reviews-block/ui/ReviewsBlock.tsx client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/features/order-chat/ui/OrderChat.tsx client/src/features/product-review/ui/ReviewDialog.tsx client/src/shared/ui/OrderMessageBody.tsx client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx && git commit -m "perf: lazy-load TipTap via RichText components" ``` --- ### Task 4: Code-split dicebear avatar styles — dynamic import map **Files:** - Modify: `client/src/shared/lib/avatar-styles.ts` — replace static imports with dynamic factory - Modify: `client/src/shared/ui/UserAvatar.tsx` — async style loading with cache - [ ] **Step 1: Rewrite avatar-styles.ts with dynamic imports** ```ts // client/src/shared/lib/avatar-styles.ts import type { Style } from '@dicebear/core' type StyleDef = { id: string label: string loader: () => Promise<{ create: any; meta: any; schema: any }> } export const AVATAR_STYLE_LOADERS: StyleDef[] = [ { id: 'bottts', label: 'Роботы', loader: () => import('@dicebear/bottts') }, { id: 'identicon', label: 'Узоры', loader: () => import('@dicebear/identicon') }, { id: 'avataaars', label: 'Персонажи', loader: () => import('@dicebear/avataaars') }, { id: 'notionists', label: 'Notion', loader: () => import('@dicebear/notionists') }, { id: 'thumbs', label: 'Thumbs', loader: () => import('@dicebear/thumbs') }, { id: 'lorelei', label: 'Lorelei', loader: () => import('@dicebear/lorelei') }, { id: 'micah', label: 'Micah', loader: () => import('@dicebear/micah') }, { id: 'pixel-art', label: 'Пиксели', loader: () => import('@dicebear/pixel-art') }, { id: 'rings', label: 'Кольца', loader: () => import('@dicebear/rings') }, { id: 'shapes', label: 'Фигуры', loader: () => import('@dicebear/shapes') }, { id: 'initials', label: 'Инициалы', loader: () => import('@dicebear/initials') }, { id: 'adventurer', label: 'Adventurer', loader: () => import('@dicebear/adventurer') }, { id: 'big-ears', label: 'Big Ears', loader: () => import('@dicebear/big-ears') }, { id: 'big-smile', label: 'Big Smile', loader: () => import('@dicebear/big-smile') }, { id: 'croodles', label: 'Croodles', loader: () => import('@dicebear/croodles') }, { id: 'fun-emoji', label: 'Fun Emoji', loader: () => import('@dicebear/fun-emoji') }, ] export const DEFAULT_STYLE_ID = 'avataaars' const styleCache = new Map>() export async function loadAvatarStyle(id: string): Promise> { if (styleCache.has(id)) { return styleCache.get(id)! } const loader = AVATAR_STYLE_LOADERS.find((s) => s.id === id) if (!loader) { const fallback = AVATAR_STYLE_LOADERS.find((s) => s.id === DEFAULT_STYLE_ID)! const mod = await fallback.loader() const style = { create: mod.create, meta: mod.meta, schema: mod.schema } styleCache.set(DEFAULT_STYLE_ID, style) return style } const mod = await loader.loader() const style = { create: mod.create, meta: mod.meta, schema: mod.schema } styleCache.set(id, style) return style } export function getStyleLabel(id: string): string { return AVATAR_STYLE_LOADERS.find((s) => s.id === id)?.label ?? id } ``` - [ ] **Step 2: Update UserAvatar to load style asynchronously** ```tsx // client/src/shared/ui/UserAvatar.tsx import { useEffect, useRef, useState } from 'react' import Avatar from '@mui/material/Avatar' import type { SxProps, Theme } from '@mui/material/styles' import { createAvatar } from '@dicebear/core' import { DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles' type UserAvatarProps = { userId: string avatarUrl?: string | null avatarStyle?: string | null size?: number sx?: SxProps } export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { const [generatedSrc, setGeneratedSrc] = useState(null) const styleId = avatarStyle || DEFAULT_STYLE_ID const styleIdRef = useRef(styleId) useEffect(() => { let cancelled = false styleIdRef.current = styleId loadAvatarStyle(styleId).then((style) => { if (!cancelled && styleIdRef.current === styleId) { const avatar = createAvatar(style, { seed: userId }) setGeneratedSrc(avatar.toDataUri()) } }) return () => { cancelled = true } }, [userId, styleId]) const src = avatarUrl || generatedSrc || '' return ( {!src && '?'} ) } ``` - [ ] **Step 3: Update AvatarSection.tsx to use async style loading** ```tsx // client/src/pages/me/ui/sections/AvatarSection.tsx // Change imports: import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles' // Remove: import { createAvatar } from '@dicebear/core' // Change the "Сгенерировать" button onClick handler: // Change the dropdown mapping: {AVATAR_STYLE_LOADERS.map((s) => ( {s.label} ))} ``` Note: `createAvatar` import from `@dicebear/core` is still needed — it's used with the loaded style. Keep it but change how the style is obtained. - [ ] **Step 4: Update AdminSettingsPage.tsx to use async style loading** ```tsx // client/src/pages/admin-settings/ui/AdminSettingsPage.tsx // Change imports: import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles' // Remove: import { createAvatar } from '@dicebear/core' — keep it, still needed // Change the "Сгенерировать" button onClick handler similarly: // Change the dropdown mapping: {AVATAR_STYLE_LOADERS.map((s) => ( {s.label} ))} ``` - [ ] **Step 5: Check if AVATAR_STYLES is used elsewhere and update** Run: `cd client/src && grep -r "AVATAR_STYLES\|getStyleById" --include="*.ts" --include="*.tsx"` Expected: Only `avatar-styles.ts` itself should reference these. All other files should use `AVATAR_STYLE_LOADERS`, `loadAvatarStyle`, and `getStyleLabel`. - [ ] **Step 6: Run lint** Run: `cd client && npm run lint` Expected: No errors - [ ] **Step 7: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/shared/lib/avatar-styles.ts client/src/shared/ui/UserAvatar.tsx client/src/pages/me/ui/sections/AvatarSection.tsx client/src/pages/admin-settings/ui/AdminSettingsPage.tsx && git commit -m "perf: dynamic import dicebear avatar styles" ``` --- ### Task 5: Lazy-load all public routes **Files:** - Modify: `client/src/app/routes/index.tsx` — convert all eager imports to lazy - [ ] **Step 1: Convert all routes to lazy + Suspense** ```tsx // client/src/app/routes/index.tsx import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { SkeletonPage } from '@/shared/ui/SkeletonPage' const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage }))) const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage }))) const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage }))) const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage }))) const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage }))) const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage }))) const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage }))) const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage }))) const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage }))) const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage }))) const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage }))) const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage }))) const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage }))) export function AppRoutes() { usePageTitleReset() return ( }>} /> }> } /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }> } /> }>} /> }>} /> ) } function usePageTitleReset() { // Keep existing import import('@/shared/lib/use-page-title').then((m) => m.usePageTitleReset()) // Actually, we need to keep this as a regular import since it's a hook } ``` Wait — `usePageTitleReset` is a hook that must be imported synchronously. Let me correct: ```tsx // client/src/app/routes/index.tsx import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { usePageTitleReset } from '@/shared/lib/use-page-title' import { SkeletonPage } from '@/shared/ui/SkeletonPage' const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage }))) const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage }))) const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage }))) const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage }))) const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage }))) const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage }))) const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage }))) const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage }))) const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage }))) const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage }))) const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage }))) const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage }))) const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage }))) export function AppRoutes() { usePageTitleReset() return ( }>} /> }> } /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }>} /> }> } /> }>} /> }>} /> ) } ``` - [ ] **Step 2: Run lint and build** Run: `cd client && npm run lint && npm run build` Expected: No errors, build succeeds with multiple chunks - [ ] **Step 3: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/app/routes/index.tsx && git commit -m "perf: lazy-load all public routes" ``` --- ### Task 6: Add React.memo to key components **Files:** - Modify: `client/src/entities/product/ui/ProductCard.tsx` - Modify: `client/src/shared/ui/OptimizedImage.tsx` - Modify: `client/src/shared/ui/UserAvatar.tsx` - Modify: `client/src/app/layout/AppHeader.tsx` - [ ] **Step 1: Add React.memo to ProductCard** ```tsx // client/src/entities/product/ui/ProductCard.tsx // Add at top: import { useCallback, useMemo, useRef } from 'react' import React from 'react' // ... rest of imports unchanged // At the bottom, wrap the export: export const ProductCard = React.memo(function ProductCard({ product, mediaHeight = 200, actions }: Props) { // ... existing component body unchanged ... }, (prev, next) => { return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions }) ``` Actually, since the function is already named, the cleanest approach: ```tsx // Change the export line from: export function ProductCard({ product, mediaHeight = 200, actions }: Props) { // To: const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => { // ... keep body ... // At the end of file, add: export const ProductCard = React.memo(ProductCardInner, (prev, next) => { return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions }) ``` - [ ] **Step 2: Add React.memo to OptimizedImage** ```tsx // client/src/shared/ui/OptimizedImage.tsx // Add at top: import { useMemo } from 'react' import React from 'react' // ... rest unchanged // Change export from: export function OptimizedImage({ // To: export const OptimizedImage = React.memo(function OptimizedImage({ // ... props unchanged ... }: OptimizedImageProps) { // ... body unchanged ... }) ``` - [ ] **Step 3: Add React.memo to UserAvatar** ```tsx // client/src/shared/ui/UserAvatar.tsx // Add at top: import { useEffect, useRef, useState } from 'react' import React from 'react' // ... rest unchanged // Change export from: export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { // To: export const UserAvatar = React.memo(function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { // ... body unchanged ... }) ``` - [ ] **Step 4: Add React.memo to AppHeader** ```tsx // client/src/app/layout/AppHeader.tsx // Add at top: import { useEffect, useState } from 'react' import React from 'react' // ... rest unchanged // Change export from: export function AppHeader() { // To: export const AppHeader = React.memo(function AppHeader() { // ... body unchanged ... }) ``` - [ ] **Step 5: Run lint and tests** Run: `cd client && npm run lint && npm test` Expected: All pass - [ ] **Step 6: Commit** ```bash cd /mnt/d/my_projects/shop && git add client/src/entities/product/ui/ProductCard.tsx client/src/shared/ui/OptimizedImage.tsx client/src/shared/ui/UserAvatar.tsx client/src/app/layout/AppHeader.tsx && git commit -m "perf: add React.memo to hot-path components" ``` --- ### Task 7: Final verification — build and test **Files:** None (verification only) - [ ] **Step 1: Run full client build** Run: `cd client && npm run build` Expected: Build succeeds, check chunk sizes — maplibre should NOT be in initial chunks - [ ] **Step 2: Run lint** Run: `cd client && npm run lint` Expected: No errors - [ ] **Step 3: Run tests** Run: `cd client && npm test` Expected: All tests pass - [ ] **Step 4: Run server tests (ensure no breakage)** Run: `cd server && npm test` Expected: All tests pass - [ ] **Step 5: Commit any remaining changes** ```bash cd /mnt/d/my_projects/shop && git status && git add -A && git commit -m "perf: final verification pass" ```