# Frontend Performance Optimization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reduce initial bundle size by ~3-4 MB and improve FCP/LCP by code-splitting heavy libraries and lazy-loading routes.
**Architecture:** Five independent optimizations: (1) dynamic maplibre imports, (2) lazy TipTap components, (3) dynamic dicebear style loading, (4) lazy route loading, (5) React.memo on hot-path components. Each can be tested independently.
**Tech Stack:** React, React Router v7, Vite, MUI, maplibre-gl, TipTap, @dicebear/core
---
### Task 1: Code-split maplibre-gl — remove global CSS, dynamic import in AddressMapPicker
**Files:**
- Modify: `client/src/main.tsx` — remove maplibre-gl CSS import
- Modify: `client/src/features/address-map-picker/ui/AddressMapPicker.tsx` — convert to lazy component with dynamic CSS+JS import
- [ ] **Step 1: Remove maplibre-gl CSS from main.tsx**
```tsx
// client/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from '@/app/App'
import '@/app/styles/global.css'
// REMOVE: import 'maplibre-gl/dist/maplibre-gl.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken())
createRoot(document.getElementById('root')!).render(
,
)
```
- [ ] **Step 2: Run dev server and verify app still starts**
Run: `cd client && npm run dev`
Expected: No errors in console about missing maplibre CSS (it won't be loaded yet, that's fine)
- [ ] **Step 3: Create lazy map component wrapper for AddressMapPicker**
The AddressMapPicker component uses `maplibre-gl` and `react-map-gl/maplibre` with static imports. We need to:
1. Move the map rendering into a separate component that dynamically imports maplibre
2. Load the CSS dynamically before rendering the map
3. Show a skeleton while loading
Create a new file `client/src/features/address-map-picker/ui/MapPickerMap.tsx`:
```tsx
import { useEffect, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import type * as maplibregl from 'maplibre-gl'
import { reverseGeocode } from '../api/map-geocoding'
import type { LatLng } from '../model/types'
let maplibreglPromise: Promise | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([
import('maplibre-gl'),
import('maplibre-gl/dist/maplibre-gl.css'),
]).then(([mod]) => mod)
}
return maplibreglPromise
}
type MapPickerMapProps = {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
center: { lat: number; lng: number }
}
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
const mapRef = useRef(null)
const [maplibre, setMaplibre] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) {
setMaplibre(mod)
setLoading(false)
}
})
return () => { cancelled = true }
}, [])
const pick = async (pos: LatLng) => {
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
}
}
if (loading || !maplibre) {
return (
)
}
return (
{
if (!('geolocation' in navigator)) return
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
},
() => {},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
)
}
```
- [ ] **Step 4: Update AddressMapPicker to use the lazy map component**
```tsx
// client/src/features/address-map-picker/ui/AddressMapPicker.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
import type { LatLng, NominatimItem } from '../model/types'
import { MapPickerMap } from './MapPickerMap'
export function AddressMapPicker(props: {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) {
const { value, onChange } = props
const [q, setQ] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState([])
const [hint, setHint] = useState(null)
const abortRef = useRef(null)
const lastQueryRef = useRef('')
const lastRequestAtRef = useRef(0)
const qTrimmed = q.trim()
const visibleResults = qTrimmed.length >= 3 ? results : []
const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 }
}, [value])
const pick = async (pos: LatLng) => {
setHint(null)
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
}
}
useEffect(() => {
const s = qTrimmed
if (s.length < 3) return
const t = window.setTimeout(async () => {
const now = Date.now()
if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return
lastQueryRef.current = s
lastRequestAtRef.current = now
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
setResults(await searchPlaces(s, ac.signal))
} catch (e) {
if ((e as { name?: string })?.name !== 'AbortError') {
setResults([])
}
} finally {
setSearching(false)
}
}, 450)
return () => { window.clearTimeout(t) }
}, [qTrimmed])
return (
Выбор на карте
setQ(e.target.value)} fullWidth />
{visibleResults.length > 0 && (
{visibleResults.map((r) => (
{
const lat = Number(r.lat)
const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
void pick({ lat, lng })
}}
>
))}
)}
{hint && (
Подсказка адреса: {hint}
)}
)
}
```
- [ ] **Step 5: Commit**
```bash
cd /mnt/d/my_projects/shop && git add client/src/main.tsx client/src/features/address-map-picker/ui/AddressMapPicker.tsx client/src/features/address-map-picker/ui/MapPickerMap.tsx && git commit -m "perf: code-split maplibre-gl in AddressMapPicker"
```
---
### Task 2: Code-split maplibre-gl in AboutPage
**Files:**
- Modify: `client/src/pages/about/ui/AboutPage.tsx` — dynamic maplibre import
- [ ] **Step 1: Create lazy map component for AboutPage**
Create `client/src/pages/about/ui/AboutMap.tsx`:
```tsx
import { useEffect, useState } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Map, { Marker } from 'react-map-gl/maplibre'
import type * as maplibregl from 'maplibre-gl'
let maplibreglPromise: Promise | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([
import('maplibre-gl'),
import('maplibre-gl/dist/maplibre-gl.css'),
]).then(([mod]) => mod)
}
return maplibreglPromise
}
const rasterStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}
type AboutMapProps = {
lat: number
lng: number
}
export function AboutMap({ lat, lng }: AboutMapProps) {
const [maplibre, setMaplibre] = useState(null)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) setMaplibre(mod)
})
return () => { cancelled = true }
}, [])
if (!maplibre) {
return (
)
}
return (
)
}
```
- [ ] **Step 2: Update AboutPage to use AboutMap**
```tsx
// client/src/pages/about/ui/AboutPage.tsx
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
import { usePageTitle } from '@/shared/lib/use-page-title'
import { AboutMap } from './AboutMap'
export function AboutPage() {
usePageTitle('О нас')
const { lat, lng } = PICKUP_COORDINATES
return (
О нас
Магазин изделий ручной работы. Мы отвечаем за качество и сроки изготовления всего, что вы видите в каталоге.
Контакты
Email:{' '}
{STORE_EMAIL}
Телефон:{' '}
{STORE_PHONE}
ВКонтакте
Забрать заказ можно по адресу самовывоза:
{PICKUP_ADDRESS_FULL}
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
)
}
```
- [ ] **Step 3: Commit**
```bash
cd /mnt/d/my_projects/shop && git add client/src/pages/about/ui/AboutPage.tsx client/src/pages/about/ui/AboutMap.tsx && git commit -m "perf: code-split maplibre-gl in AboutPage"
```
---
### Task 3: Code-split TipTap — lazy RichText components
**Files:**
- Create: `client/src/shared/ui/RichTextMessageContent.lazy.tsx`
- Create: `client/src/shared/ui/RichTextMessageEditor.lazy.tsx`
- Modify: All files importing RichTextMessageContent/RichTextMessageEditor — update imports
- [ ] **Step 1: Create lazy wrapper for RichTextMessageContent**
```tsx
// client/src/shared/ui/RichTextMessageContent.lazy.tsx
import { lazy, Suspense } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
const RichTextMessageContentImpl = lazy(() =>
import('./RichTextMessageContent').then((m) => ({ default: m.RichTextMessageContent })),
)
type RichTextMessageContentProps = {
value: string
tone?: 'default' | 'review' | 'chat'
}
export function RichTextMessageContent(props: RichTextMessageContentProps) {
return (
}
>
)
}
```
- [ ] **Step 2: Create lazy wrapper for RichTextMessageEditor**
```tsx
// client/src/shared/ui/RichTextMessageEditor.lazy.tsx
import { lazy, Suspense } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
const RichTextMessageEditorImpl = lazy(() =>
import('./RichTextMessageEditor').then((m) => ({ default: m.RichTextMessageEditor })),
)
type RichTextMessageEditorProps = {
value: string
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
}
export function RichTextMessageEditor(props: RichTextMessageEditorProps) {
return (
}
>
)
}
```
- [ ] **Step 3: Update all imports to use lazy versions**
In each of these files, change the import path:
```
# Files to update (change import path):
client/src/pages/me/ui/sections/MessagesPage.tsx
client/src/features/product-review/ui/ProductReviewsList.tsx
client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
client/src/features/order-detail/ui/OrderDetailContent.tsx
client/src/features/order-chat/ui/OrderChat.tsx
client/src/features/product-review/ui/ReviewDialog.tsx
client/src/shared/ui/OrderMessageBody.tsx
client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx
```
Change in each file:
```diff
- import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
+ import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
```
```diff
- import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
+ import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
```
Use this command to update all files:
```bash
cd /mnt/d/my_projects/shop/client/src
find . -name 'MessagesPage.tsx' -o -name 'ProductReviewsList.tsx' -o -name 'ReviewsBlock.tsx' -o -name 'OrderDetailContent.tsx' -o -name 'OrderChat.tsx' -o -name 'ReviewDialog.tsx' -o -name 'OrderMessageBody.tsx' -o -name 'AdminReviewsPage.tsx' | while read f; do
sed -i "s|from '@/shared/ui/RichTextMessageContent'|from '@/shared/ui/RichTextMessageContent.lazy'|" "$f"
sed -i "s|from '@/shared/ui/RichTextMessageEditor'|from '@/shared/ui/RichTextMessageEditor.lazy'|" "$f"
done
```
- [ ] **Step 4: Run lint to verify no import errors**
Run: `cd client && npm run lint`
Expected: No errors related to RichText imports
- [ ] **Step 5: Commit**
```bash
cd /mnt/d/my_projects/shop && git add client/src/shared/ui/RichTextMessageContent.lazy.tsx client/src/shared/ui/RichTextMessageEditor.lazy.tsx client/src/pages/me/ui/sections/MessagesPage.tsx client/src/features/product-review/ui/ProductReviewsList.tsx client/src/widgets/reviews-block/ui/ReviewsBlock.tsx client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/features/order-chat/ui/OrderChat.tsx client/src/features/product-review/ui/ReviewDialog.tsx client/src/shared/ui/OrderMessageBody.tsx client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx && git commit -m "perf: lazy-load TipTap via RichText components"
```
---
### Task 4: Code-split dicebear avatar styles — dynamic import map
**Files:**
- Modify: `client/src/shared/lib/avatar-styles.ts` — replace static imports with dynamic factory
- Modify: `client/src/shared/ui/UserAvatar.tsx` — async style loading with cache
- [ ] **Step 1: Rewrite avatar-styles.ts with dynamic imports**
```ts
// client/src/shared/lib/avatar-styles.ts
import type { Style } from '@dicebear/core'
type StyleDef = {
id: string
label: string
loader: () => Promise<{ create: any; meta: any; schema: any }>
}
export const AVATAR_STYLE_LOADERS: StyleDef[] = [
{ id: 'bottts', label: 'Роботы', loader: () => import('@dicebear/bottts') },
{ id: 'identicon', label: 'Узоры', loader: () => import('@dicebear/identicon') },
{ id: 'avataaars', label: 'Персонажи', loader: () => import('@dicebear/avataaars') },
{ id: 'notionists', label: 'Notion', loader: () => import('@dicebear/notionists') },
{ id: 'thumbs', label: 'Thumbs', loader: () => import('@dicebear/thumbs') },
{ id: 'lorelei', label: 'Lorelei', loader: () => import('@dicebear/lorelei') },
{ id: 'micah', label: 'Micah', loader: () => import('@dicebear/micah') },
{ id: 'pixel-art', label: 'Пиксели', loader: () => import('@dicebear/pixel-art') },
{ id: 'rings', label: 'Кольца', loader: () => import('@dicebear/rings') },
{ id: 'shapes', label: 'Фигуры', loader: () => import('@dicebear/shapes') },
{ id: 'initials', label: 'Инициалы', loader: () => import('@dicebear/initials') },
{ id: 'adventurer', label: 'Adventurer', loader: () => import('@dicebear/adventurer') },
{ id: 'big-ears', label: 'Big Ears', loader: () => import('@dicebear/big-ears') },
{ id: 'big-smile', label: 'Big Smile', loader: () => import('@dicebear/big-smile') },
{ id: 'croodles', label: 'Croodles', loader: () => import('@dicebear/croodles') },
{ id: 'fun-emoji', label: 'Fun Emoji', loader: () => import('@dicebear/fun-emoji') },
]
export const DEFAULT_STYLE_ID = 'avataaars'
const styleCache = new Map>()
export async function loadAvatarStyle(id: string): Promise