Files
shop-server/docs/superpowers/plans/2026-05-24-frontend-performance-optimization-plan.md
T
2026-05-24 19:55:43 +05:00

1169 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```