39 KiB
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
// 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:
- Move the map rendering into a separate component that dynamically imports maplibre
- Load the CSS dynamically before rendering the map
- Show a skeleton while loading
Create a new file client/src/features/address-map-picker/ui/MapPickerMap.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
// 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
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:
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
// 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
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
// 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
// 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:
- import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
+ import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
- import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
+ import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
Use this command to update all files:
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
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
// 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
// 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
// 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
// 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
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
// 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:
// 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
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
// 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:
// 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
// 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
// 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
// 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
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
cd /mnt/d/my_projects/shop && git status && git add -A && git commit -m "perf: final verification pass"