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

39 KiB
Raw Blame History

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:

  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:

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"