From 261233442eb31643002e855f142c031574eaf69c Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 19:23:38 +0500 Subject: [PATCH 01/37] docs: add frontend performance optimization design spec --- ...rontend-performance-optimization-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-frontend-performance-optimization-design.md diff --git a/docs/superpowers/specs/2026-05-24-frontend-performance-optimization-design.md b/docs/superpowers/specs/2026-05-24-frontend-performance-optimization-design.md new file mode 100644 index 0000000..3a89d88 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-frontend-performance-optimization-design.md @@ -0,0 +1,129 @@ +# Frontend Performance Optimization — Design Spec + +**Date:** 2026-05-24 +**Author:** opencode +**Status:** Draft + +## Problem + +Lighthouse performance score: **0.51** + +| Metric | Value | Score | +|--------|-------|-------| +| FCP | 5.5s | 0 | +| LCP | 10.8s | 0 | +| TBT | 10ms | 1 | +| CLS | 0.122 | 0.84 | + +**Root causes:** +- 9.9 MB total transfer size (dev mode, 368 requests) +- maplibre-gl (1.3 MB) loaded on every page, used only in About + AddressMapPicker +- 18 @dicebear packages (~2 MB) statically imported, never code-split +- TipTap (116 KB) always loaded, used only in Messages/Reviews +- Only 2 of 14+ routes lazy-loaded +- Zero React.memo usage — uncontrolled re-renders + +## Architecture + +### 1. Code-split maplibre-gl + +**Files affected:** +- `client/src/main.tsx` — remove global `import 'maplibre-gl/dist/maplibre-gl.css'` +- `client/src/features/address-map-picker/ui/AddressMapPicker.tsx` — dynamic import +- `client/src/pages/about/ui/AboutPage.tsx` — dynamic import + +**Implementation:** +- Remove CSS import from `main.tsx` +- In components that use map, load CSS + JS via `import()` inside `useEffect` or `React.lazy` +- Show loading skeleton while map loads + +### 2. Code-split TipTap + +**Files affected:** +- `client/src/shared/ui/RichTextMessageContent.tsx` — lazy wrapper +- `client/src/shared/ui/RichTextMessageEditor.tsx` — lazy wrapper +- `client/src/features/order-chat/` — add Suspense boundary +- `client/src/widgets/reviews-block/` — add Suspense boundary + +**Implementation:** +- Create lazy exports in `shared/ui/index.ts` +- Wrap TipTap-dependent components in `}>` + +### 3. Code-split dicebear avatar styles + +**Files affected:** +- `client/src/shared/lib/avatar-styles.ts` — dynamic import map + +**Implementation:** +- Replace static imports with factory map: `{ styleName: () => import('@dicebear/style') }` +- Cache loaded styles in `Map` +- Load style on first use, not at module initialization + +### 4. Lazy-load all public routes + +**Files affected:** +- `client/src/app/routes/index.tsx` — convert all routes to lazy + Suspense + +**Routes to lazy-load:** +- `/` (HomePage) +- `/auth` (AuthPage) +- `/auth/callback` (AuthCallbackPage) +- `/cart` (CartPage) +- `/checkout` (CheckoutPage) +- `/about` (AboutPage) +- `/info/*` (InfoPage) +- `/privacy` (PrivacyPolicyPage) +- `/terms` (TermsPage) +- `/products/:id` (ProductPage) + +**Exception:** 404 route stays synchronous (lightweight). + +**Fallback:** `SkeletonPage` for consistency with existing admin/me lazy routes. + +### 5. React.memo on key components + +**Files affected:** +- `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` + +**Implementation:** +- `ProductCard`: memo with custom comparator comparing `product.id` and `onClick` reference +- `OptimizedImage`: memo comparing `src`, `alt`, `priority` +- `UserAvatar`: memo comparing `userId`, `style` +- `AppHeader`: memo (default shallow comparison sufficient) + +## Data Flow + +``` +User navigates → Router loads lazy route chunk → Component mounts + ↓ + Dynamic imports (maplibre/TipTap) fire + ↓ + Suspense shows fallback → Content renders +``` + +## Error Handling + +- All `React.lazy` components wrapped in `` with fallback +- Dynamic imports wrapped in try/catch with error boundary fallback +- Dicebear style loading: fallback to default style if dynamic import fails + +## Testing + +- Verify routes still work after lazy conversion (manual navigation) +- Verify map loads correctly in AboutPage and AddressMapPicker +- Verify TipTap renders in Messages and Reviews +- Verify avatars render with correct styles +- Run `npm run lint` and `npm test` after changes +- Run `npm run build` to verify production bundle + +## Success Criteria + +- Initial bundle size reduced by ~3-4 MB (dev mode) +- FCP improved from 5.5s to < 2.5s +- LCP improved from 10.8s to < 4.0s +- No regressions in functionality +- All tests pass +- Lint passes From 3b10d8764dd52e98f28953893d6418ecef4eaa46 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 19:37:43 +0500 Subject: [PATCH 02/37] perf: code-split maplibre-gl in AddressMapPicker --- .../ui/AddressMapPicker.tsx | 110 +---------- .../address-map-picker/ui/MapPickerMap.tsx | 178 ++++++++++++++++++ client/src/main.tsx | 1 - 3 files changed, 181 insertions(+), 108 deletions(-) create mode 100644 client/src/features/address-map-picker/ui/MapPickerMap.tsx diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx index 2d46d42..5eeb5f8 100644 --- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -1,30 +1,24 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import CircularProgress from '@mui/material/CircularProgress' -import IconButton from '@mui/material/IconButton' 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 Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' -import * as maplibregl from 'maplibre-gl' -import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre' 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 mapRef = useRef(null) const [q, setQ] = useState('') const [searching, setSearching] = useState(false) - const [locating, setLocating] = useState(false) const [results, setResults] = useState([]) const [hint, setHint] = useState(null) const abortRef = useRef(null) @@ -36,7 +30,7 @@ export function AddressMapPicker(props: { const center = useMemo(() => { if (value) return { lat: value.lat, lng: value.lng } - return { lat: 55.751244, lng: 37.618423 } // Москва (fallback) + return { lat: 55.751244, lng: 37.618423 } }, [value]) const pick = async (pos: LatLng) => { @@ -60,7 +54,6 @@ export function AddressMapPicker(props: { } const t = window.setTimeout(async () => { - // throttle: не чаще 1 запроса в 900ms const now = Date.now() if (now - lastRequestAtRef.current < 900) return if (s === lastQueryRef.current) return @@ -128,7 +121,6 @@ export function AddressMapPicker(props: { const lat = Number(r.lat) const lng = Number(r.lon) if (!Number.isFinite(lat) || !Number.isFinite(lng)) return - mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 }) void pick({ lat, lng }) }} > @@ -138,103 +130,7 @@ export function AddressMapPicker(props: { )} - - { - const { lng, lat } = e.lngLat - void pick({ lat, lng }) - }} - > - {value && ( - { - const { lng, lat } = e.lngLat - void pick({ lat, lng }) - }} - anchor="bottom" - > - - - )} - - - - - { - if (!('geolocation' in navigator)) return - setLocating(true) - 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 }) - setLocating(false) - }, - () => { - setLocating(false) - }, - { 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="Моё местоположение" - > - {locating ? : } - - - - + {hint && ( diff --git a/client/src/features/address-map-picker/ui/MapPickerMap.tsx b/client/src/features/address-map-picker/ui/MapPickerMap.tsx new file mode 100644 index 0000000..e7e38eb --- /dev/null +++ b/client/src/features/address-map-picker/ui/MapPickerMap.tsx @@ -0,0 +1,178 @@ +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) + const [locating, setLocating] = useState(false) + + 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 ( + + { + const { lng, lat } = e.lngLat + void pick({ lat, lng }) + }} + > + {value && ( + { + const { lng, lat } = e.lngLat + void pick({ lat, lng }) + }} + anchor="bottom" + > + + + )} + + + + + { + if (!('geolocation' in navigator)) return + setLocating(true) + 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 }) + setLocating(false) + }, + () => { + setLocating(false) + }, + { 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="Моё местоположение" + > + {locating ? : } + + + + + ) +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 8802b4e..7e48bfd 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,7 +2,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app/App' import '@/app/styles/global.css' -import 'maplibre-gl/dist/maplibre-gl.css' import { readStoredToken, tokenSet } from '@/shared/model/auth' tokenSet(readStoredToken()) From c2b685c0dce421a6c6e4b33e1020ca84321b0dd8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 19:38:40 +0500 Subject: [PATCH 03/37] perf: code-split maplibre-gl in AboutPage --- client/src/pages/about/ui/AboutMap.tsx | 107 ++++++++++++++++++++++++ client/src/pages/about/ui/AboutPage.tsx | 54 +----------- 2 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 client/src/pages/about/ui/AboutMap.tsx diff --git a/client/src/pages/about/ui/AboutMap.tsx b/client/src/pages/about/ui/AboutMap.tsx new file mode 100644 index 0000000..16693f9 --- /dev/null +++ b/client/src/pages/about/ui/AboutMap.tsx @@ -0,0 +1,107 @@ +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 ( + + + + + + + + ) +} diff --git a/client/src/pages/about/ui/AboutPage.tsx b/client/src/pages/about/ui/AboutPage.tsx index 1e2e25b..7a31de1 100644 --- a/client/src/pages/about/ui/AboutPage.tsx +++ b/client/src/pages/about/ui/AboutPage.tsx @@ -3,24 +3,10 @@ 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 * as maplibregl from 'maplibre-gl' -import Map, { Marker } from 'react-map-gl/maplibre' 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' - -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' }], -} +import { AboutMap } from './AboutMap' export function AboutPage() { usePageTitle('О нас') @@ -63,43 +49,7 @@ export function AboutPage() { - - - - - - - + ) From 8a4fd53bc40ab668ca6195f36e92b8753c9a594b Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 19:39:40 +0500 Subject: [PATCH 04/37] perf: lazy-load TipTap via RichText components --- .../src/features/order-chat/ui/OrderChat.tsx | 2 +- .../order-detail/ui/OrderDetailContent.tsx | 2 +- .../product-review/ui/ProductReviewsList.tsx | 2 +- .../product-review/ui/ReviewDialog.tsx | 2 +- .../admin-reviews/ui/AdminReviewsPage.tsx | 2 +- .../src/pages/me/ui/sections/MessagesPage.tsx | 4 +-- client/src/shared/ui/OrderMessageBody.tsx | 2 +- .../shared/ui/RichTextMessageContent.lazy.tsx | 26 +++++++++++++++++ .../shared/ui/RichTextMessageEditor.lazy.tsx | 28 +++++++++++++++++++ .../widgets/reviews-block/ui/ReviewsBlock.tsx | 2 +- 10 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 client/src/shared/ui/RichTextMessageContent.lazy.tsx create mode 100644 client/src/shared/ui/RichTextMessageEditor.lazy.tsx diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx index ad07c43..7346365 100644 --- a/client/src/features/order-chat/ui/OrderChat.tsx +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -9,7 +9,7 @@ import { fetchAdminAvatar } from '@/entities/user/api/user-api' import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' -import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy' import { UserAvatar } from '@/shared/ui/UserAvatar' type Message = { diff --git a/client/src/features/order-detail/ui/OrderDetailContent.tsx b/client/src/features/order-detail/ui/OrderDetailContent.tsx index 36f5b0c..e935ae1 100644 --- a/client/src/features/order-detail/ui/OrderDetailContent.tsx +++ b/client/src/features/order-detail/ui/OrderDetailContent.tsx @@ -21,7 +21,7 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' -import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy' import { UserAvatar } from '@/shared/ui/UserAvatar' import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm' diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index 130950f..db7167e 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -10,7 +10,7 @@ import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api' import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { OptimizedImage } from '@/shared/ui/OptimizedImage' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' import { UserAvatar } from '@/shared/ui/UserAvatar' function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { diff --git a/client/src/features/product-review/ui/ReviewDialog.tsx b/client/src/features/product-review/ui/ReviewDialog.tsx index 959ad2c..257699d 100644 --- a/client/src/features/product-review/ui/ReviewDialog.tsx +++ b/client/src/features/product-review/ui/ReviewDialog.tsx @@ -10,7 +10,7 @@ import Rating from '@mui/material/Rating' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import axios from 'axios' -import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy' type Props = { productTitle: string | null diff --git a/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx b/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx index e356f0f..9e8fdcf 100644 --- a/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx +++ b/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx @@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' export function AdminReviewsPage() { const qc = useQueryClient() diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx index b558264..e28df4c 100644 --- a/client/src/pages/me/ui/sections/MessagesPage.tsx +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -20,8 +20,8 @@ import { usePageTitle } from '@/shared/lib/use-page-title' import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' -import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy' import { UserAvatar } from '@/shared/ui/UserAvatar' export function MessagesPage() { diff --git a/client/src/shared/ui/OrderMessageBody.tsx b/client/src/shared/ui/OrderMessageBody.tsx index ac98ba3..2ceb1d1 100644 --- a/client/src/shared/ui/OrderMessageBody.tsx +++ b/client/src/shared/ui/OrderMessageBody.tsx @@ -1,5 +1,5 @@ import Box from '@mui/material/Box' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' type Props = { text: string diff --git a/client/src/shared/ui/RichTextMessageContent.lazy.tsx b/client/src/shared/ui/RichTextMessageContent.lazy.tsx new file mode 100644 index 0000000..b955c7d --- /dev/null +++ b/client/src/shared/ui/RichTextMessageContent.lazy.tsx @@ -0,0 +1,26 @@ +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 ( + + + + } + > + + + ) +} diff --git a/client/src/shared/ui/RichTextMessageEditor.lazy.tsx b/client/src/shared/ui/RichTextMessageEditor.lazy.tsx new file mode 100644 index 0000000..dd54cc6 --- /dev/null +++ b/client/src/shared/ui/RichTextMessageEditor.lazy.tsx @@ -0,0 +1,28 @@ +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 ( + + + + } + > + + + ) +} diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index a288fca..20a4fb3 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -10,7 +10,7 @@ import { useQuery } from '@tanstack/react-query' import { Link as RouterLink } from 'react-router-dom' import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api' import { OptimizedImage } from '@/shared/ui/OptimizedImage' -import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy' import { UserAvatar } from '@/shared/ui/UserAvatar' function formatReviewDate(iso: string): string { From 0dd5f8b8ff618b241d245cc5a65e3c4f92aad62f Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 19:42:17 +0500 Subject: [PATCH 05/37] perf: dynamic import dicebear avatar styles --- .../admin-settings/ui/AdminSettingsPage.tsx | 10 +- .../pages/me/ui/sections/AvatarSection.tsx | 10 +- client/src/shared/lib/avatar-styles.ts | 118 +++++++----------- client/src/shared/ui/UserAvatar.tsx | 32 +++-- 4 files changed, 75 insertions(+), 95 deletions(-) diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index bd805e5..2e59982 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { apiClient } from '@/shared/api/client' -import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' +import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles' import { $user, updateProfileFx } from '@/shared/model/auth' import type { UpdateProfileParams } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' @@ -165,7 +165,7 @@ export function AdminSettingsPage() { Стиль