1169 lines
39 KiB
Markdown
1169 lines
39 KiB
Markdown
# 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(
|
||
<StrictMode>
|
||
<App />
|
||
</StrictMode>,
|
||
)
|
||
```
|
||
|
||
- [ ] **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<typeof maplibregl> | 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<MapRef | null>(null)
|
||
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(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 (
|
||
<Box
|
||
sx={{
|
||
height: 280,
|
||
borderRadius: 2,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<CircularProgress size={24} />
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
height: 280,
|
||
borderRadius: 2,
|
||
overflow: 'hidden',
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
<Map
|
||
mapLib={maplibre}
|
||
ref={mapRef}
|
||
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
|
||
style={{ width: '100%', height: 280 }}
|
||
mapStyle={{
|
||
version: 8,
|
||
sources: {
|
||
osm: {
|
||
type: 'raster',
|
||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||
tileSize: 256,
|
||
attribution: '© OpenStreetMap contributors',
|
||
},
|
||
},
|
||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||
}}
|
||
onClick={(e: MapMouseEvent) => {
|
||
const { lng, lat } = e.lngLat
|
||
void pick({ lat, lng })
|
||
}}
|
||
>
|
||
{value && (
|
||
<Marker
|
||
longitude={value.lng}
|
||
latitude={value.lat}
|
||
draggable
|
||
onDragEnd={(e) => {
|
||
const { lng, lat } = e.lngLat
|
||
void pick({ lat, lng })
|
||
}}
|
||
anchor="bottom"
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: 18,
|
||
height: 18,
|
||
bgcolor: 'primary.main',
|
||
borderRadius: '50%',
|
||
border: 2,
|
||
borderColor: 'background.paper',
|
||
boxShadow: 3,
|
||
}}
|
||
/>
|
||
</Marker>
|
||
)}
|
||
</Map>
|
||
|
||
<Tooltip title="Моё местоположение">
|
||
<span>
|
||
<IconButton
|
||
size="small"
|
||
disabled={!('geolocation' in navigator)}
|
||
onClick={() => {
|
||
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="Моё местоположение"
|
||
>
|
||
<MyLocationOutlinedIcon fontSize="small" />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
</Box>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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<NominatimItem[]>([])
|
||
const [hint, setHint] = useState<string | null>(null)
|
||
const abortRef = useRef<AbortController | null>(null)
|
||
const lastQueryRef = useRef<string>('')
|
||
const lastRequestAtRef = useRef<number>(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 (
|
||
<Stack spacing={1.5}>
|
||
<Typography variant="subtitle2">Выбор на карте</Typography>
|
||
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
|
||
<Button
|
||
variant="outlined"
|
||
onClick={async () => {
|
||
const s = q.trim()
|
||
if (!s) return
|
||
abortRef.current?.abort()
|
||
const ac = new AbortController()
|
||
abortRef.current = ac
|
||
setSearching(true)
|
||
try {
|
||
lastQueryRef.current = s
|
||
lastRequestAtRef.current = Date.now()
|
||
setResults(await searchPlaces(s, ac.signal))
|
||
} finally {
|
||
setSearching(false)
|
||
}
|
||
}}
|
||
disabled={searching || !q.trim()}
|
||
sx={{ minWidth: 160 }}
|
||
>
|
||
{searching ? <CircularProgress size={18} /> : 'Найти'}
|
||
</Button>
|
||
</Stack>
|
||
|
||
{visibleResults.length > 0 && (
|
||
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||
{visibleResults.map((r) => (
|
||
<ListItemButton
|
||
key={`${r.lat}:${r.lon}:${r.display_name}`}
|
||
onClick={() => {
|
||
const lat = Number(r.lat)
|
||
const lng = Number(r.lon)
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||
void pick({ lat, lng })
|
||
}}
|
||
>
|
||
<ListItemText primary={r.display_name} />
|
||
</ListItemButton>
|
||
))}
|
||
</List>
|
||
)}
|
||
|
||
<MapPickerMap value={value} onChange={onChange} center={center} />
|
||
|
||
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{hint && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
Подсказка адреса: {hint}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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<typeof maplibregl> | 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<typeof maplibregl | null>(null)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
loadMaplibre().then((mod) => {
|
||
if (!cancelled) setMaplibre(mod)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
if (!maplibre) {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
height: 380,
|
||
borderRadius: 2,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<CircularProgress size={24} />
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
height: 380,
|
||
borderRadius: 2,
|
||
overflow: 'hidden',
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
<Map
|
||
mapLib={maplibre}
|
||
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
|
||
style={{ width: '100%', height: 380 }}
|
||
mapStyle={rasterStyle}
|
||
scrollZoom={false}
|
||
dragRotate={false}
|
||
dragPan={false}
|
||
doubleClickZoom={false}
|
||
keyboard={false}
|
||
touchZoomRotate={false}
|
||
>
|
||
<Marker longitude={lng} latitude={lat} anchor="bottom">
|
||
<Box
|
||
sx={{
|
||
width: 20,
|
||
height: 20,
|
||
bgcolor: 'primary.main',
|
||
borderRadius: '50%',
|
||
border: 2,
|
||
borderColor: 'background.paper',
|
||
boxShadow: 3,
|
||
}}
|
||
/>
|
||
</Marker>
|
||
</Map>
|
||
</Box>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<Box>
|
||
<Typography variant="h4" gutterBottom>
|
||
О нас
|
||
</Typography>
|
||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||
Магазин изделий ручной работы. Мы отвечаем за качество и сроки изготовления всего, что вы видите в каталоге.
|
||
</Typography>
|
||
|
||
<Stack spacing={3}>
|
||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Контакты
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||
Email:{' '}
|
||
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
||
{STORE_EMAIL}
|
||
</Link>
|
||
</Typography>
|
||
<Typography variant="body2">
|
||
Телефон:{' '}
|
||
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
|
||
{STORE_PHONE}
|
||
</Link>
|
||
</Typography>
|
||
<Typography variant="body2">
|
||
<Link href={VK_URL} target="_blank" rel="noopener noreferrer" underline="hover">
|
||
ВКонтакте
|
||
</Link>
|
||
</Typography>
|
||
<Typography sx={{ mb: 1, mt: 2 }}>Забрать заказ можно по адресу самовывоза:</Typography>
|
||
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
|
||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
|
||
</Typography>
|
||
</Paper>
|
||
|
||
<AboutMap lat={lat} lng={lng} />
|
||
</Stack>
|
||
</Box>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<Suspense
|
||
fallback={
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 1 }}>
|
||
<CircularProgress size={16} />
|
||
</Box>
|
||
}
|
||
>
|
||
<RichTextMessageContentImpl {...props} />
|
||
</Suspense>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<Suspense
|
||
fallback={
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||
<CircularProgress size={20} />
|
||
</Box>
|
||
}
|
||
>
|
||
<RichTextMessageEditorImpl {...props} />
|
||
</Suspense>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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<string, Style<any>>()
|
||
|
||
export async function loadAvatarStyle(id: string): Promise<Style<any>> {
|
||
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<Theme>
|
||
}
|
||
|
||
export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) {
|
||
const [generatedSrc, setGeneratedSrc] = useState<string | null>(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 (
|
||
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
|
||
{!src && '?'}
|
||
</Avatar>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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:
|
||
<Button
|
||
variant="outlined"
|
||
onClick={async () => {
|
||
const seed = `${user.id}_${Date.now()}`
|
||
const style = await loadAvatarStyle(selectedStyle)
|
||
const avatar = createAvatar(style, { seed })
|
||
setPreviewSrc(avatar.toDataUri())
|
||
setPreviewStyle(selectedStyle)
|
||
}}
|
||
>
|
||
Сгенерировать
|
||
</Button>
|
||
|
||
// Change the dropdown mapping:
|
||
{AVATAR_STYLE_LOADERS.map((s) => (
|
||
<MenuItem key={s.id} value={s.id}>
|
||
{s.label}
|
||
</MenuItem>
|
||
))}
|
||
```
|
||
|
||
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:
|
||
<Button
|
||
variant="outlined"
|
||
onClick={async () => {
|
||
const seed = `${user.id}_${Date.now()}`
|
||
const style = await loadAvatarStyle(selectedStyle)
|
||
const avatar = createAvatar(style, { seed })
|
||
setPreviewSrc(avatar.toDataUri())
|
||
setPreviewStyle(selectedStyle)
|
||
}}
|
||
>
|
||
Сгенерировать
|
||
</Button>
|
||
|
||
// Change the dropdown mapping:
|
||
{AVATAR_STYLE_LOADERS.map((s) => (
|
||
<MenuItem key={s.id} value={s.id}>
|
||
{s.label}
|
||
</MenuItem>
|
||
))}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<MainLayout>
|
||
<Routes>
|
||
<Route path="/" element={<Suspense fallback={<SkeletonPage />}><HomePage /></Suspense>} />
|
||
<Route
|
||
path="/admin/*"
|
||
element={
|
||
<Suspense fallback={<SkeletonPage />}>
|
||
<AdminLayoutPage />
|
||
</Suspense>
|
||
}
|
||
/>
|
||
<Route path="/auth" element={<Suspense fallback={<SkeletonPage />}><AuthPage /></Suspense>} />
|
||
<Route path="/auth/callback" element={<Suspense fallback={<SkeletonPage />}><AuthCallbackPage /></Suspense>} />
|
||
<Route path="/cart" element={<Suspense fallback={<SkeletonPage />}><CartPage /></Suspense>} />
|
||
<Route path="/checkout" element={<Suspense fallback={<SkeletonPage />}><CheckoutPage /></Suspense>} />
|
||
<Route path="/about" element={<Suspense fallback={<SkeletonPage />}><AboutPage /></Suspense>} />
|
||
<Route path="/info" element={<Suspense fallback={<SkeletonPage />}><InfoPage /></Suspense>} />
|
||
<Route path="/privacy" element={<Suspense fallback={<SkeletonPage />}><PrivacyPolicyPage /></Suspense>} />
|
||
<Route path="/terms" element={<Suspense fallback={<SkeletonPage />}><TermsPage /></Suspense>} />
|
||
<Route
|
||
path="/me/*"
|
||
element={
|
||
<Suspense fallback={<SkeletonPage />}>
|
||
<MeLayoutPage />
|
||
</Suspense>
|
||
}
|
||
/>
|
||
<Route path="/products/:id" element={<Suspense fallback={<SkeletonPage />}><ProductPage /></Suspense>} />
|
||
<Route path="*" element={<Suspense fallback={<SkeletonPage />}><NotFoundPage /></Suspense>} />
|
||
</Routes>
|
||
</MainLayout>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<MainLayout>
|
||
<Routes>
|
||
<Route path="/" element={<Suspense fallback={<SkeletonPage />}><HomePage /></Suspense>} />
|
||
<Route
|
||
path="/admin/*"
|
||
element={
|
||
<Suspense fallback={<SkeletonPage />}>
|
||
<AdminLayoutPage />
|
||
</Suspense>
|
||
}
|
||
/>
|
||
<Route path="/auth" element={<Suspense fallback={<SkeletonPage />}><AuthPage /></Suspense>} />
|
||
<Route path="/auth/callback" element={<Suspense fallback={<SkeletonPage />}><AuthCallbackPage /></Suspense>} />
|
||
<Route path="/cart" element={<Suspense fallback={<SkeletonPage />}><CartPage /></Suspense>} />
|
||
<Route path="/checkout" element={<Suspense fallback={<SkeletonPage />}><CheckoutPage /></Suspense>} />
|
||
<Route path="/about" element={<Suspense fallback={<SkeletonPage />}><AboutPage /></Suspense>} />
|
||
<Route path="/info" element={<Suspense fallback={<SkeletonPage />}><InfoPage /></Suspense>} />
|
||
<Route path="/privacy" element={<Suspense fallback={<SkeletonPage />}><PrivacyPolicyPage /></Suspense>} />
|
||
<Route path="/terms" element={<Suspense fallback={<SkeletonPage />}><TermsPage /></Suspense>} />
|
||
<Route
|
||
path="/me/*"
|
||
element={
|
||
<Suspense fallback={<SkeletonPage />}>
|
||
<MeLayoutPage />
|
||
</Suspense>
|
||
}
|
||
/>
|
||
<Route path="/products/:id" element={<Suspense fallback={<SkeletonPage />}><ProductPage /></Suspense>} />
|
||
<Route path="*" element={<Suspense fallback={<SkeletonPage />}><NotFoundPage /></Suspense>} />
|
||
</Routes>
|
||
</MainLayout>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **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"
|
||
```
|