# 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